diff --git a/.github/CHANGELOG.rst b/.github/CHANGELOG.rst index 16307b9e45c..f04dcc0b3df 100644 --- a/.github/CHANGELOG.rst +++ b/.github/CHANGELOG.rst @@ -2,28 +2,61 @@ Change Log ========== -2.8.0 (Quokka) +2.9.0 (Ragdoll) --------- *Release date: TBD* +- Added a setting to expect no feedback from teams or adjudicators. Thanks Daan Koning! +- Allowed scores to be given in any increments. +- Actions taken through the API are now logged. +- Participants' private URLs now show their barcode for checkin. Thank you to Miha Frangež! + - The API includes participants' barcode numbers on participant endpoints. + + +2.8.1 +----- +*Release date: 27 January 2024* + +- Fixed failing draw generation with byes (BACKEND-BWA) +- Avoided showing points in private URL table for uncredited rounds (BACKEND-BVY) +- Corrected ordering of ballots in private URL tables (#2369) +- Fixed draw strength metrics counting unconfirmed ballots +- API: Re-added ``seq`` for motions in Round endpoint +- Hid real names from ballot forms if code names used +- Fixed break category form showing general error + + +2.8.0 (Quokka) +--------- +*Release date: 28 November 2023* + +- The term "iron person" is now used throughout the platform for consistency and inclusivity. Thanks to @dcorks for the pull request! + - The number of times a team has had an iron-speaker is now tracked as a team metric. - Added new emoji from Unicode 12 and 13. Thank you to Daan Koning for the pull request! (`#2143 `_) - Info Slides can now use rich-text formatting (e.g. bold, links, etc). Thanks to Trần Trang Linh for adding this feature! - Speaker and break category forms have better validation and fewer fields. -- The number of times a team has had an iron-speaker is now tracked. +- Tournaments can be created specifying private URL use directly. Thanks to Sébastien Dunne Fulmer! - Implemented support for APDA-style tournaments with: - - Avoidance for a team to repeatedly meet pulled-up teams, - - A new two-team draw generator to minimize penalties globally within brackets, - - Team seeding for the first round, - - The ability to give ranks to speeches in addition to speaker scores, and - - A preset to enable these options. + - Avoidance for a team to repeatedly meet pulled-up teams, + - A new two-team draw generator to minimize penalties globally within brackets, + - Team seeding for the first round, + - The ability to give ranks to speeches in addition to speaker scores, and + - A preset to enable these options. - API Updates: - - Documentation is now automatically generated and available under the ``/api/schema/redoc/`` path on all sites. - - Preformed panels are now accessible using the API. - - Team and speaker scores by round has a new endpoint. Thanks to Ido Wolf for the feature! - - The site's timezone is shown in the root endpoint. + - Documentation is now automatically generated and available under the ``/api/schema/redoc/`` path on all sites. + - Preformed panels are now accessible using the API. + - Team and speaker scores by round has a new endpoint. Thanks to Ido Wolf for the feature! + - The site's timezone is shown in the root endpoint. Thanks to Daan Koning! - \+ so many more little improvements and fixes! +2.7.8 +----- +*Release date: 13 August 2023* + +- Fixed some issues with Docker-based deployments. + + 2.7.7 ----- *Release date: 23 April 2023* diff --git a/Dockerfile b/Dockerfile index 61c9fd71b85..36972f882c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ # Grab a python image FROM python:3.9 +SHELL ["/bin/bash", "--login", "-c"] # Just needed for all things python (note this is setting an env variable) ENV PYTHONUNBUFFERED 1 @@ -12,14 +13,13 @@ ENV IN_DOCKER 1 # Setup Node/NPM RUN apt-get update RUN apt-get install -y curl nginx -RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - -RUN apt-get install -y nodejs npm +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # Copy all our files into the baseimage and cd to that directory -RUN mkdir /tcd WORKDIR /tcd -# Can this be skipped? Takes ages -ADD . /tcd/ +COPY . /tcd/ + +RUN nvm install && nvm use # Set git to use HTTPS (SSH is often blocked by firewalls) RUN git config --global url."https://".insteadOf git:// diff --git a/config/nginx.conf b/config/nginx.conf index 37a81959ecc..6e2f96bcd39 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,16 +1,14 @@ # This is customised from https://github.com/heroku/heroku-buildpack-nginx.git -# Done so in order to properly proxy to both and asgi and wasgi server -# Super useful template: -# https://github.com/CLClark/fcc-stock-trading-app/blob/9017f001255718c2e0fd24eb8267df02267d6cd8/config/nginx.conf.erb +# Done so in order to properly proxy to both and asgi and wsgi server daemon off; # Heroku dynos have at least 4 cores. -worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>; +worker_processes 4; events { use epoll; accept_mutex on; - worker_connections <%= ENV['NGINX_WORKER_CONNECTIONS'] || 1024 %>; + worker_connections 1024; } http { diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb index 2a110ef53dc..c572bbf10e2 100644 --- a/config/nginx.conf.erb +++ b/config/nginx.conf.erb @@ -1,5 +1,5 @@ # This is customised from https://github.com/heroku/heroku-buildpack-nginx.git -# Done so in order to properly proxy to both and asgi and wasgi server +# Done so in order to properly proxy to both and asgi and wsgi server # Super useful template: # https://github.com/CLClark/fcc-stock-trading-app/blob/9017f001255718c2e0fd24eb8267df02267d6cd8/config/nginx.conf.erb diff --git a/docker-compose.yml b/docker-compose.yml index 6c7349af1c9..21f82809a08 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,9 +41,9 @@ services: - DOCKER_REDIS=1 - USING_NGINX=1 ports: - - "127.0.0.1:8000:8000" + - "8000:8000" volumes: - - ./tabbycat/settings:/tcd/tabbycat/settings + - .:/tcd working_dir: /tcd worker: @@ -60,7 +60,7 @@ services: - DOCKER_REDIS=1 - USING_NGINX=1 volumes: - - ./tabbycat/settings:/tcd/tabbycat/settings + - .:/tcd working_dir: /tcd volumes: diff --git a/docs/conf.py b/docs/conf.py index bba3854f8c2..92399ba36e3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,9 +58,9 @@ # built documents. # # The short X.Y version. -version = '2.8' +version = '2.9' # The full version, including alpha/beta/rc tags. -release = '2.8.0-dev' +release = '2.9.0-dev' rst_epilog = """ .. |vrelease| replace:: v{release} diff --git a/tabbycat/actionlog/management/commands/keytimes.py b/tabbycat/actionlog/management/commands/keytimes.py index 48cfe94b3aa..279f898161b 100644 --- a/tabbycat/actionlog/management/commands/keytimes.py +++ b/tabbycat/actionlog/management/commands/keytimes.py @@ -38,35 +38,35 @@ def handle_tournament(self, tournament, **options): queryset = round.actionlogentry_set.order_by('timestamp') # Find the last adj save before venue allocation - venues_last_allocated = queryset.filter(type=ActionLogEntry.ACTION_TYPE_VENUES_AUTOALLOCATE).last() - adj_saves = queryset.filter(type=ActionLogEntry.ACTION_TYPE_ADJUDICATORS_SAVE) + venues_last_allocated = queryset.filter(type=ActionLogEntry.ActionType.VENUES_AUTOALLOCATE).last() + adj_saves = queryset.filter(type=ActionLogEntry.ActionType.ADJUDICATORS_SAVE) if venues_last_allocated: adj_saves = adj_saves.filter(timestamp__lte=venues_last_allocated.timestamp) last_adj_save = adj_saves.last() entries = [ - queryset.filter(type=ActionLogEntry.ACTION_TYPE_DRAW_CREATE).first(), + queryset.filter(type=ActionLogEntry.ActionType.DRAW_CREATE).first(), queryset.filter(type__in=[ - ActionLogEntry.ACTION_TYPE_DEBATE_IMPORTANCE_EDIT, - ActionLogEntry.ACTION_TYPE_DEBATE_IMPORTANCE_AUTO, + ActionLogEntry.ActionType.DEBATE_IMPORTANCE_EDIT, + ActionLogEntry.ActionType.DEBATE_IMPORTANCE_AUTO, ]).first(), queryset.filter(type__in=[ - ActionLogEntry.ACTION_TYPE_ADJUDICATORS_AUTO, - ActionLogEntry.ACTION_TYPE_PREFORMED_PANELS_DEBATES_AUTO, + ActionLogEntry.ActionType.ADJUDICATORS_AUTO, + ActionLogEntry.ActionType.PREFORMED_PANELS_DEBATES_AUTO, ]).first(), last_adj_save, venues_last_allocated, # "start at" times goes here queryset.filter(type__in=[ - ActionLogEntry.ACTION_TYPE_BALLOT_CREATE, - ActionLogEntry.ACTION_TYPE_BALLOT_SUBMIT, + ActionLogEntry.ActionType.BALLOT_CREATE, + ActionLogEntry.ActionType.BALLOT_SUBMIT, ]).first(), - queryset.filter(type=ActionLogEntry.ACTION_TYPE_BALLOT_CONFIRM).first(), + queryset.filter(type=ActionLogEntry.ActionType.BALLOT_CONFIRM).first(), queryset.filter(type__in=[ - ActionLogEntry.ACTION_TYPE_BALLOT_CREATE, - ActionLogEntry.ACTION_TYPE_BALLOT_SUBMIT, + ActionLogEntry.ActionType.BALLOT_CREATE, + ActionLogEntry.ActionType.BALLOT_SUBMIT, ]).last(), - queryset.filter(type=ActionLogEntry.ACTION_TYPE_BALLOT_CONFIRM).last(), + queryset.filter(type=ActionLogEntry.ActionType.BALLOT_CONFIRM).last(), ] times = [timezone.localtime(entry.timestamp) if entry else None for entry in entries] date = next((t for t in times[:5][::-1] if t is not None), None) diff --git a/tabbycat/actionlog/migrations/0013_actionlogentry_agent_alter_actionlogentry_type.py b/tabbycat/actionlog/migrations/0013_actionlogentry_agent_alter_actionlogentry_type.py new file mode 100644 index 00000000000..6b3c270bda4 --- /dev/null +++ b/tabbycat/actionlog/migrations/0013_actionlogentry_agent_alter_actionlogentry_type.py @@ -0,0 +1,125 @@ +# Generated by Django 4.1.7 on 2024-01-11 17:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("actionlog", "0012_auto_20200705_1317"), + ] + + operations = [ + migrations.AddField( + model_name="actionlogentry", + name="agent", + field=models.CharField( + choices=[("a", "API"), ("w", "Web")], + default="w", + max_length=1, + verbose_name="agent", + ), + ), + 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.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"), + ], + max_length=10, + verbose_name="type", + ), + ), + ] diff --git a/tabbycat/actionlog/mixins.py b/tabbycat/actionlog/mixins.py index 16dba9f2319..2a10e3fe8df 100644 --- a/tabbycat/actionlog/mixins.py +++ b/tabbycat/actionlog/mixins.py @@ -101,9 +101,9 @@ def log_action(self, **kwargs): log = ActionLogEntry.objects.log(ip_address=ip_address, **action_log_fields) # Notify the actionlog consumer to broadcast the event - if self.tournament: + if tournament := action_log_fields.get('tournament'): print('Broadcasting notification of ActionLogEntryConsumer') - group_name = ActionLogEntryConsumer.group_prefix + "_" + self.tournament.slug + group_name = ActionLogEntryConsumer.group_prefix + "_" + tournament.slug async_to_sync(get_channel_layer().group_send)(group_name, { "type": "send_json", "data": log.serialize, diff --git a/tabbycat/actionlog/models.py b/tabbycat/actionlog/models.py index f423062394e..893ccc1cfa6 100644 --- a/tabbycat/actionlog/models.py +++ b/tabbycat/actionlog/models.py @@ -21,139 +21,100 @@ class ActionLogEntry(models.Model): # These aren't generated automatically - all generations of these should # be done in views (not models). - ACTION_TYPE_BALLOT_CHECKIN = 'ba.ckin' - ACTION_TYPE_BALLOT_CREATE = 'ba.crea' - ACTION_TYPE_BALLOT_CONFIRM = 'ba.conf' - ACTION_TYPE_BALLOT_DISCARD = 'ba.disc' - ACTION_TYPE_BALLOT_SUBMIT = 'ba.subm' - ACTION_TYPE_BALLOT_EDIT = 'ba.edit' - ACTION_TYPE_FEEDBACK_SUBMIT = 'fb.subm' - ACTION_TYPE_FEEDBACK_SAVE = 'fb.save' - ACTION_TYPE_TEST_SCORE_EDIT = 'ts.edit' - ACTION_TYPE_ADJUDICATOR_NOTE_SET = 'aj.note' # obsolete - ACTION_TYPE_DRAW_CREATE = 'dr.crea' - ACTION_TYPE_DRAW_CONFIRM = 'dr.conf' - ACTION_TYPE_DRAW_REGENERATE = 'dr.rege' - ACTION_TYPE_ADJUDICATORS_SAVE = 'aa.save' - ACTION_TYPE_ADJUDICATORS_AUTO = 'aa.auto' - ACTION_TYPE_VENUES_SAVE = 've.save' - ACTION_TYPE_VENUES_AUTOALLOCATE = 've.auto' - ACTION_TYPE_VENUE_CATEGORIES_EDIT = 've.ca.edit' - ACTION_TYPE_VENUE_CONSTRAINTS_EDIT = 've.co.edit' - ACTION_TYPE_MATCHUP_SAVE = 'mu.save' - ACTION_TYPE_SIDES_SAVE = 'ms.save' - ACTION_TYPE_DRAW_RELEASE = 'dr.rele' - ACTION_TYPE_DRAW_UNRELEASE = 'dr.unre' - ACTION_TYPE_DIVISIONS_SAVE = 'dv.save' # obsolete - ACTION_TYPE_MOTION_EDIT = 'mo.edit' - ACTION_TYPE_MOTIONS_RELEASE = 'mo.rele' - ACTION_TYPE_MOTIONS_UNRELEASE = 'mo.unre' - ACTION_TYPE_DEBATE_IMPORTANCE_AUTO = 'db.im.auto' - ACTION_TYPE_DEBATE_IMPORTANCE_EDIT = 'db.im.edit' - ACTION_TYPE_ROUND_START_TIME_SET = 'rd.st.set' - ACTION_TYPE_ROUND_ADVANCE = 'rd.adva' # obsolete, kept to avoid breaking old sites - ACTION_TYPE_ROUND_COMPLETE = 'rd.comp' - ACTION_TYPE_ADJUDICATOR_BREAK_SET = 'br.aj.set' - ACTION_TYPE_BREAK_ELIGIBILITY_EDIT = 'br.el.edit' - ACTION_TYPE_BREAK_CATEGORIES_EDIT = 'br.ca.edit' - ACTION_TYPE_BREAK_GENERATE_ALL = 'br.gene' - ACTION_TYPE_BREAK_UPDATE_ALL = 'br.upda' - ACTION_TYPE_BREAK_UPDATE_ONE = 'br.upd1' - ACTION_TYPE_BREAK_EDIT_REMARKS = 'br.rm.edit' - ACTION_TYPE_AVAIL_TEAMS_SAVE = 'av.tm.save' - ACTION_TYPE_AVAIL_ADJUDICATORS_SAVE = 'av.aj.save' - ACTION_TYPE_AVAIL_VENUES_SAVE = 'av.ve.save' - ACTION_TYPE_OPTIONS_EDIT = 'op.edit' - ACTION_TYPE_SPEAKER_ELIGIBILITY_EDIT = 'se.edit' - ACTION_TYPE_SPEAKER_CATEGORIES_EDIT = 'se.ca.edit' - ACTION_TYPE_SIMPLE_IMPORT_INSTITUTIONS = 'si.inst' - ACTION_TYPE_SIMPLE_IMPORT_VENUES = 'si.venu' - ACTION_TYPE_SIMPLE_IMPORT_TEAMS = 'si.team' - ACTION_TYPE_SIMPLE_IMPORT_ADJUDICATORS = 'si.adju' - ACTION_TYPE_UPDATE_ADJUDICATOR_SCORES = 'aj.sc.upda' - ACTION_TYPE_CONFLICTS_ADJ_TEAM_EDIT = 'ac.at.edit' - ACTION_TYPE_CONFLICTS_ADJ_ADJ_EDIT = 'ac.aa.edit' - ACTION_TYPE_CONFLICTS_ADJ_INST_EDIT = 'ac.ai.edit' - ACTION_TYPE_CONFLICTS_TEAM_INST_EDIT = 'ac.ti.edit' - ACTION_TYPE_CHECKIN_SPEAK_GENERATE = 'ch.sp.gene' - ACTION_TYPE_CHECKIN_ADJ_GENERATE = 'ch.aj.gene' - ACTION_TYPE_CHECKIN_VENUES_GENERATE = 'ch.ve.gene' - ACTION_TYPE_PREFORMED_PANELS_CREATE = 'pp.crea' - ACTION_TYPE_PREFORMED_PANELS_IMPORTANCE_AUTO = 'pp.im.auto' - ACTION_TYPE_PREFORMED_PANELS_IMPORTANCE_EDIT = 'pp.im.edit' - ACTION_TYPE_PREFORMED_PANELS_ADJUDICATOR_AUTO = 'pp.aj.auto' - ACTION_TYPE_PREFORMED_PANELS_ADJUDICATOR_EDIT = 'pp.aj.auto' - ACTION_TYPE_PREFORMED_PANELS_DEBATES_AUTO = 'pp.db.auto' - ACTION_TYPE_USER_INVITE = 'ur.inv' - - ACTION_TYPE_CHOICES = ( - (ACTION_TYPE_BALLOT_DISCARD , _("Discarded ballot set")), - (ACTION_TYPE_BALLOT_CHECKIN , _("Checked in ballot set")), - (ACTION_TYPE_BALLOT_CREATE , _("Created ballot set")), # For tab assistants, not debaters - (ACTION_TYPE_BALLOT_EDIT , _("Edited ballot set")), - (ACTION_TYPE_BALLOT_CONFIRM , _("Confirmed ballot set")), - (ACTION_TYPE_BALLOT_SUBMIT , _("Submitted ballot set from the public form")), # For debaters, not tab assistants - (ACTION_TYPE_FEEDBACK_SUBMIT , _("Submitted feedback from the public form")), # For debaters, not tab assistants - (ACTION_TYPE_FEEDBACK_SAVE , _("Saved feedback")), # For tab assistants, not debaters - (ACTION_TYPE_TEST_SCORE_EDIT , _("Edited adjudicator base score")), - (ACTION_TYPE_ADJUDICATOR_NOTE_SET , _("Set adjudicator note")), - (ACTION_TYPE_ADJUDICATORS_SAVE , _("Saved adjudicator allocation")), - (ACTION_TYPE_ADJUDICATORS_AUTO , _("Auto-allocated adjudicators")), - (ACTION_TYPE_VENUES_SAVE , _("Saved a room manual edit")), - (ACTION_TYPE_VENUES_AUTOALLOCATE , _("Auto-allocated rooms")), - (ACTION_TYPE_VENUE_CATEGORIES_EDIT , _("Edited room categories")), - (ACTION_TYPE_VENUE_CONSTRAINTS_EDIT , _("Edited room constraints")), - (ACTION_TYPE_DRAW_CREATE , _("Created draw")), - (ACTION_TYPE_DRAW_CONFIRM , _("Confirmed draw")), - (ACTION_TYPE_DRAW_REGENERATE , _("Regenerated draw")), - (ACTION_TYPE_DRAW_RELEASE , _("Released draw")), - (ACTION_TYPE_DRAW_UNRELEASE , _("Unreleased draw")), - (ACTION_TYPE_MATCHUP_SAVE , _("Saved a matchup manual edit")), - (ACTION_TYPE_SIDES_SAVE , _("Saved the sides status of a matchup")), - (ACTION_TYPE_DIVISIONS_SAVE , _("Saved divisions")), - (ACTION_TYPE_MOTION_EDIT , _("Added/edited motion")), - (ACTION_TYPE_MOTIONS_RELEASE , _("Released motions")), - (ACTION_TYPE_MOTIONS_UNRELEASE , _("Unreleased motions")), - (ACTION_TYPE_DEBATE_IMPORTANCE_AUTO , _("Auto-prioritized debate importance")), - (ACTION_TYPE_DEBATE_IMPORTANCE_EDIT , _("Edited debate importance")), - (ACTION_TYPE_ADJUDICATOR_BREAK_SET , _("Changed adjudicator breaking status")), - (ACTION_TYPE_BREAK_ELIGIBILITY_EDIT , _("Edited break eligibility")), - (ACTION_TYPE_BREAK_CATEGORIES_EDIT , _("Edited break categories")), - (ACTION_TYPE_BREAK_GENERATE_ALL , _("Generated the team break for all categories")), - (ACTION_TYPE_BREAK_UPDATE_ALL , _("Edited breaking team remarks and updated all team breaks")), - (ACTION_TYPE_BREAK_UPDATE_ONE , _("Edited breaking team remarks and updated this team break")), - (ACTION_TYPE_BREAK_EDIT_REMARKS , _("Edited breaking team remarks")), - (ACTION_TYPE_ROUND_START_TIME_SET , _("Set start time")), - (ACTION_TYPE_ROUND_ADVANCE , _("Advanced the current round to")), - (ACTION_TYPE_ROUND_COMPLETE , _("Marked round as completed")), - (ACTION_TYPE_AVAIL_TEAMS_SAVE , _("Edited teams availability")), - (ACTION_TYPE_AVAIL_ADJUDICATORS_SAVE , _("Edited adjudicators availability")), - (ACTION_TYPE_AVAIL_VENUES_SAVE , _("Edited room availability")), - (ACTION_TYPE_OPTIONS_EDIT , _("Edited tournament options")), - (ACTION_TYPE_SPEAKER_ELIGIBILITY_EDIT , _("Edited speaker category eligibility")), - (ACTION_TYPE_SPEAKER_CATEGORIES_EDIT , _("Edited speaker categories")), - (ACTION_TYPE_SIMPLE_IMPORT_INSTITUTIONS , _("Imported institutions using the simple importer")), - (ACTION_TYPE_SIMPLE_IMPORT_VENUES , _("Imported rooms using the simple importer")), - (ACTION_TYPE_SIMPLE_IMPORT_TEAMS , _("Imported teams using the simple importer")), - (ACTION_TYPE_SIMPLE_IMPORT_ADJUDICATORS , _("Imported adjudicators using the simple importer")), - (ACTION_TYPE_UPDATE_ADJUDICATOR_SCORES , _("Updated adjudicator scores in bulk")), - (ACTION_TYPE_CONFLICTS_ADJ_TEAM_EDIT , _("Edited adjudicator-team conflicts")), - (ACTION_TYPE_CONFLICTS_ADJ_ADJ_EDIT , _("Edited adjudicator-adjudicator conflicts")), - (ACTION_TYPE_CONFLICTS_ADJ_INST_EDIT , _("Edited adjudicator-institution conflicts")), - (ACTION_TYPE_CONFLICTS_TEAM_INST_EDIT , _("Edited team-institution conflicts")), - (ACTION_TYPE_CHECKIN_SPEAK_GENERATE , _("Generated check in identifiers for speakers")), - (ACTION_TYPE_CHECKIN_ADJ_GENERATE , _("Generated check in identifiers for adjudicators")), - (ACTION_TYPE_CHECKIN_VENUES_GENERATE , _("Generated check in identifiers for rooms")), - (ACTION_TYPE_PREFORMED_PANELS_CREATE , _("Created preformed panels")), - (ACTION_TYPE_PREFORMED_PANELS_IMPORTANCE_AUTO , _("Auto-prioritized preformed panels")), - (ACTION_TYPE_PREFORMED_PANELS_IMPORTANCE_EDIT , _("Edited preformed panel importance")), - (ACTION_TYPE_PREFORMED_PANELS_ADJUDICATOR_AUTO, _("Auto-allocated adjudicators to preformed panels")), - (ACTION_TYPE_PREFORMED_PANELS_ADJUDICATOR_EDIT, _("Edited preformed panel adjudicator")), - (ACTION_TYPE_PREFORMED_PANELS_DEBATES_AUTO , _("Auto-allocated preformed panels to debates")), - (ACTION_TYPE_USER_INVITE , _("Invited user to the instance")), - ) - - type = models.CharField(max_length=10, choices=ACTION_TYPE_CHOICES, + class ActionType(models.TextChoices): + ADJUDICATOR_BREAK_SET = 'br.aj.set', _("Changed adjudicator breaking status") + ADJUDICATOR_CREATE = 'aj.crea', _("Created adjudicator") + ADJUDICATOR_EDIT = 'aj.edit', _("Edited adjudicator") + ADJUDICATOR_NOTE_SET = 'aj.note', _("Set adjudicator note") # obsolete + ADJUDICATORS_AUTO = 'aa.auto', _("Auto-allocated adjudicators") + ADJUDICATORS_SAVE = 'aa.save', _("Saved adjudicator allocation") + AVAIL_ADJUDICATORS_SAVE = 'av.aj.save', _("Edited adjudicators availability") + AVAIL_SAVE = 'av.save', _("Edited availability") + AVAIL_TEAMS_SAVE = 'av.tm.save', _("Edited teams availability") + AVAIL_VENUES_SAVE = 'av.ve.save', _("Edited room availability") + BALLOT_CHECKIN = 'ba.ckin', _("Checked in ballot set") + BALLOT_CONFIRM = 'ba.conf', _("Confirmed ballot set") + BALLOT_CREATE = 'ba.crea', _("Created ballot set") + BALLOT_DISCARD = 'ba.disc', _("Discarded ballot set") + BALLOT_EDIT = 'ba.edit', _("Edited ballot set") + BALLOT_SUBMIT = 'ba.subm', _("Submitted ballot set from the public form") + BREAK_CATEGORIES_EDIT = 'br.ca.edit', _("Edited break categories") + BREAK_DELETE = 'br.del', _("Deleted team break for category") + BREAK_EDIT_REMARKS = 'br.rm.edit', _("Edited breaking team remarks") + BREAK_ELIGIBILITY_EDIT = 'br.el.edit', _("Edited break eligibility") + BREAK_GENERATE_ALL = 'br.gene', _("Generated the team break for all categories") + BREAK_GENERATE_ONE = 'br.gen1', _("Generated the team break for one category") + BREAK_UPDATE_ALL = 'br.upda', _("Edited breaking team remarks and updated all team breaks") + BREAK_UPDATE_ONE = 'br.upd1', _("Edited breaking team remarks and updated this team break") + CHECKIN_ADJ_GENERATE = 'ch.aj.gene', _("Generated check in identifiers for adjudicators") + CHECKIN_SPEAK_GENERATE = 'ch.sp.gene', _("Generated check in identifiers for speakers") + CHECKIN_VENUES_GENERATE = 'ch.ve.gene', _("Generated check in identifiers for rooms") + CONFLICTS_ADJ_ADJ_EDIT = 'ac.aa.edit', _("Edited adjudicator-adjudicator conflicts") + CONFLICTS_ADJ_INST_EDIT = 'ac.ai.edit', _("Edited adjudicator-institution conflicts") + CONFLICTS_ADJ_TEAM_EDIT = 'ac.at.edit', _("Edited adjudicator-team conflicts") + CONFLICTS_TEAM_INST_EDIT = 'ac.ti.edit', _("Edited team-institution conflicts") + DEBATE_CREATE = 'db.crea', _("Created debate") + DEBATE_EDIT = 'db.edit', _("Edited debate") + DEBATE_IMPORTANCE_AUTO = 'db.im.auto', _("Auto-prioritized debate importance") + DEBATE_IMPORTANCE_EDIT = 'db.im.edit', _("Edited debate importance") + DIVISIONS_SAVE = 'dv.save', _("Saved divisions") # obsolete + DRAW_CONFIRM = 'dr.conf', _("Confirmed draw") + DRAW_CREATE = 'dr.crea', _("Created draw") + DRAW_REGENERATE = 'dr.rege', _("Regenerated draw") + DRAW_RELEASE = 'dr.rele', _("Released draw") + DRAW_UNRELEASE = 'dr.unre', _("Unreleased draw") + FEEDBACK_QUESTION_CREATE = 'fq.crea', _("Created feedback question") + FEEDBACK_QUESTION_EDIT = 'fq.edit', _("Edited feedback question") + FEEDBACK_SAVE = 'fb.save', _("Saved feedback") + FEEDBACK_SUBMIT = 'fb.subm', _("Submitted feedback from the public form") + INSTITUTION_CREATE = 'in.crea', _("Created institution") + INSTITUTION_EDIT = 'in.edit', _("Edited institution") + MATCHUP_SAVE = 'mu.save', _("Saved a matchup manual edit") + MOTION_EDIT = 'mo.edit', _("Added/edited motion") + MOTIONS_RELEASE = 'mo.rele', _("Released motions") + MOTIONS_UNRELEASE = 'mo.unre', _("Unreleased motions") + OPTIONS_EDIT = 'op.edit', _("Edited tournament options") + PREFORMED_PANELS_ADJUDICATOR_AUTO = 'pp.aj.auto', _("Auto-allocated adjudicators to preformed panels") + PREFORMED_PANELS_ADJUDICATOR_EDIT = 'pp.aj.edit', _("Edited preformed panel adjudicator") + PREFORMED_PANELS_CREATE = 'pp.crea', _("Created preformed panels") + PREFORMED_PANELS_DEBATES_AUTO = 'pp.db.auto', _("Auto-allocated preformed panels to debates") + PREFORMED_PANELS_DELETE = 'pp.del', _("Deleted preformed panels") + PREFORMED_PANELS_IMPORTANCE_AUTO = 'pp.im.auto', _("Auto-prioritized preformed panels") + PREFORMED_PANELS_IMPORTANCE_EDIT = 'pp.im.edit', _("Edited preformed panel importance") + ROUND_ADVANCE = 'rd.adva', _("Advanced the current round to") # obsolete + ROUND_COMPLETE = 'rd.comp', _("Marked round as completed") + ROUND_CREATE = 'rd.crea', _("Created round") + ROUND_EDIT = 'rd.edit', _("Edited round") + ROUND_START_TIME_SET = 'rd.st.set', _("Set start time") + SIDES_SAVE = 'ms.save', _("Saved the sides status of a matchup") + SIMPLE_IMPORT_ADJUDICATORS = 'si.adju', _("Imported adjudicators using the simple importer") + SIMPLE_IMPORT_INSTITUTIONS = 'si.inst', _("Imported institutions using the simple importer") + SIMPLE_IMPORT_TEAMS = 'si.team', _("Imported teams using the simple importer") + 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_EDIT = 'sp.edit', _("Edited speaker") + SPEAKER_ELIGIBILITY_EDIT = 'se.edit', _("Edited speaker category eligibility") + TEAM_CREATE = 'te.crea', _("Created team") + TEAM_EDIT = 'te.edit', _("Edited team") + TEST_SCORE_EDIT = 'ts.edit', _("Edited adjudicator base score") + TOURNAMENT_CREATE = 'to.crea', _("Created tournament") + TOURNAMENT_EDIT = 'to.edit', _("Edited tournament") + UPDATE_ADJUDICATOR_SCORES = 'aj.sc.upda', _("Updated adjudicator scores in bulk") + USER_INVITE = 'ur.inv', _("Invited user to the instance") + VENUE_CATEGORIES_EDIT = 've.ca.edit', _("Edited room categories") + VENUE_CATEGORY_CREATE = 've.ca.crea', _("Created room category") + VENUE_CONSTRAINTS_EDIT = 've.co.edit', _("Edited room constraints") + VENUE_CREATE = 've.crea', _("Created room") + VENUE_EDIT = 've.edit', _("Edited room") + VENUES_AUTOALLOCATE = 've.auto', _("Auto-allocated rooms") + VENUES_SAVE = 've.save', _("Saved a room manual edit") + + class Agent(models.TextChoices): + API = 'a', _("API") + WEB = 'w', _("Web") + + type = models.CharField(max_length=10, choices=ActionType.choices, verbose_name=_("type")) timestamp = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name=_("timestamp")) @@ -175,6 +136,8 @@ class ActionLogEntry(models.Model): object_id = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("object ID")) content_object = GenericForeignKey('content_type', 'object_id') + agent = models.CharField(max_length=1, choices=Agent.choices, default=Agent.WEB, + verbose_name=_("agent")) objects = ActionLogManager() diff --git a/tabbycat/adjallocation/consumers.py b/tabbycat/adjallocation/consumers.py index 332a936753d..396eea56a3d 100644 --- a/tabbycat/adjallocation/consumers.py +++ b/tabbycat/adjallocation/consumers.py @@ -82,7 +82,7 @@ def allocate_debate_adjs(self, event): debates, panels = allocator.allocate() copy_panels_to_debates(debates, panels) - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_PREFORMED_PANELS_DEBATES_AUTO) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.PREFORMED_PANELS_DEBATES_AUTO) msg = _("Successfully auto-allocated preformed panels to debates.") level = 'success' @@ -106,7 +106,7 @@ def allocate_debate_adjs(self, event): for alloc in allocation: alloc.save() - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_ADJUDICATORS_AUTO) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.ADJUDICATORS_AUTO) if user_warnings: msg = ngettext( @@ -151,7 +151,7 @@ def allocate_panel_adjs(self, event): for alloc in allocation: alloc.save() - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_PREFORMED_PANELS_ADJUDICATOR_AUTO) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.PREFORMED_PANELS_ADJUDICATOR_AUTO) content = self.reserialize_panels(SimplePanelAllocationSerializer, round) if user_warnings: @@ -214,7 +214,7 @@ def prioritise_debates(self, event): elif priority_method == 'bracket': self._prioritise_by_bracket(debates, 'bracket') - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_DEBATE_IMPORTANCE_AUTO) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.DEBATE_IMPORTANCE_AUTO) content = self.reserialize_debates(SimpleDebateImportanceSerializer, round, debates) msg = _("Succesfully auto-prioritised debates.") self.return_response(content, event['extra']['group_name'], msg, 'success') @@ -245,7 +245,7 @@ def prioritise_panels(self, event): panels = panels.annotate(bracket_mid=(F('bracket_max') + F('bracket_min')) / 2) self._prioritise_by_bracket(panels, 'bracket_mid') - self.log_action(event['extra'], rd, ActionLogEntry.ACTION_TYPE_PREFORMED_PANELS_IMPORTANCE_AUTO) + self.log_action(event['extra'], rd, ActionLogEntry.ActionType.PREFORMED_PANELS_IMPORTANCE_AUTO) content = self.reserialize_panels(SimplePanelImportanceSerializer, rd, panels) msg = _("Succesfully auto-prioritised preformed panels.") self.return_response(content, event['extra']['group_name'], msg, 'success') @@ -261,7 +261,7 @@ def create_preformed_panels(self, event): 'liveness': liveness, }) - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_PREFORMED_PANELS_CREATE) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.PREFORMED_PANELS_CREATE) content = self.reserialize_panels(EditPanelAdjsPanelSerializer, round) if round.prev is None: diff --git a/tabbycat/adjallocation/views.py b/tabbycat/adjallocation/views.py index 44a4fe11f70..7308c287d56 100644 --- a/tabbycat/adjallocation/views.py +++ b/tabbycat/adjallocation/views.py @@ -189,7 +189,7 @@ class AdjudicatorTeamConflictsView(BaseAdjudicatorConflictsView): view_permission = Permission.VIEW_ADJ_TEAM_CONFLICTS edit_permission = Permission.EDIT_ADJ_TEAM_CONFLICTS - action_log_type = ActionLogEntry.ACTION_TYPE_CONFLICTS_ADJ_TEAM_EDIT + action_log_type = ActionLogEntry.ActionType.CONFLICTS_ADJ_TEAM_EDIT formset_model = AdjudicatorTeamConflict page_title = gettext_lazy("Adjudicator-Team Conflicts") save_text = gettext_lazy("Save Adjudicator-Team Conflicts") @@ -236,7 +236,7 @@ class AdjudicatorAdjudicatorConflictsView(BaseAdjudicatorConflictsView): view_permission = Permission.VIEW_ADJ_ADJ_CONFLICTS edit_permission = Permission.EDIT_ADJ_ADJ_CONFLICTS - action_log_type = ActionLogEntry.ACTION_TYPE_CONFLICTS_ADJ_ADJ_EDIT + action_log_type = ActionLogEntry.ActionType.CONFLICTS_ADJ_ADJ_EDIT formset_model = AdjudicatorAdjudicatorConflict page_title = gettext_lazy("Adjudicator-Adjudicator Conflicts") save_text = gettext_lazy("Save Adjudicator-Adjudicator Conflicts") @@ -279,7 +279,7 @@ class AdjudicatorInstitutionConflictsView(BaseAdjudicatorConflictsView): view_permission = Permission.VIEW_ADJ_INST_CONFLICTS edit_permission = Permission.EDIT_ADJ_INST_CONFLICTS - action_log_type = ActionLogEntry.ACTION_TYPE_CONFLICTS_ADJ_INST_EDIT + action_log_type = ActionLogEntry.ActionType.CONFLICTS_ADJ_INST_EDIT formset_model = AdjudicatorInstitutionConflict page_title = gettext_lazy("Adjudicator-Institution Conflicts") save_text = gettext_lazy("Save Adjudicator-Institution Conflicts") @@ -321,7 +321,7 @@ class TeamInstitutionConflictsView(BaseAdjudicatorConflictsView): view_permission = Permission.VIEW_TEAM_INST_CONFLICTS edit_permission = Permission.EDIT_TEAM_INST_CONFLICTS - action_log_type = ActionLogEntry.ACTION_TYPE_CONFLICTS_TEAM_INST_EDIT + action_log_type = ActionLogEntry.ActionType.CONFLICTS_TEAM_INST_EDIT formset_model = TeamInstitutionConflict page_title = gettext_lazy("Team-Institution Conflicts") save_text = gettext_lazy("Save Team-Institution Conflicts") diff --git a/tabbycat/adjfeedback/dbutils.py b/tabbycat/adjfeedback/dbutils.py index 1fedf3f2bd2..d854e1251d2 100644 --- a/tabbycat/adjfeedback/dbutils.py +++ b/tabbycat/adjfeedback/dbutils.py @@ -100,8 +100,10 @@ def add_feedback(debate, submitter_type, user, probability=1.0, discarded=False, if debate.round.tournament.pref('feedback_from_teams') == 'all-adjs': sources_and_subjects = [(team, adj) for team in debate.teams for adj in debate.adjudicators.all()] - else: + elif debate.round.tournament.pref('feedback_from_teams') == 'orallist': sources_and_subjects = [(team, debate.adjudicators.chair) for team in debate.teams] + else: + sources_and_subjects = [] sources_and_subjects.extend(itertools.permutations( (adj for adj, position in debate.adjudicators.with_debateadj_types()), 2)) diff --git a/tabbycat/adjfeedback/forms.py b/tabbycat/adjfeedback/forms.py index bedf8f3295c..3b724fef54f 100644 --- a/tabbycat/adjfeedback/forms.py +++ b/tabbycat/adjfeedback/forms.py @@ -331,8 +331,10 @@ def adj_choice(adj, debate, pos): da.adjudicator.submitted = da.submitted if tournament.pref('feedback_from_teams') == 'all-adjs': das = debate.adjudicators.with_positions() - else: + elif tournament.pref('feedback_from_teams') == 'orallist': das = debate.adjudicators.voting_with_positions() + else: + das = [] round_choices = [] for adj, pos in das: diff --git a/tabbycat/adjfeedback/models.py b/tabbycat/adjfeedback/models.py index cfc530f5380..b6524f7473b 100644 --- a/tabbycat/adjfeedback/models.py +++ b/tabbycat/adjfeedback/models.py @@ -256,7 +256,7 @@ def __str__(self): def _unique_unconfirm_args(self): kwargs = super()._unique_unconfirm_args() - if self.source_team is not None and self.source_team.debate.round.tournament.pref('feedback_from_teams') != 'all-adjs': + if self.source_team is not None and self.source_team.debate.round.tournament.pref('feedback_from_teams') == 'orallist': kwargs.pop('adjudicator') return kwargs diff --git a/tabbycat/adjfeedback/progress.py b/tabbycat/adjfeedback/progress.py index db7bf0961e2..928428d1b31 100644 --- a/tabbycat/adjfeedback/progress.py +++ b/tabbycat/adjfeedback/progress.py @@ -257,6 +257,7 @@ def __init__(self, team, tournament=None): tournament = team.tournament self.enforce_orallist = (tournament.pref("show_splitting_adjudicators") and tournament.pref("ballots_per_debate_prelim") == 'per-adj') + self.expect_orallists = tournament.pref("feedback_from_teams") in ['orallist', 'all-adjs'] self.expect_all_adjs = tournament.pref("feedback_from_teams") == 'all-adjs' super().__init__(tournament) @@ -301,7 +302,7 @@ def get_expected_trackers(self): attrgetter('source', 'target'), attrgetter('source_team', 'adjudicator')) - else: + elif self.expect_orallists: # If teams submit only on orallists, there is one tracker for each # debate for which there is a confirmed ballot, and the round is not # silent. @@ -309,6 +310,8 @@ def get_expected_trackers(self): for dt in debateteams] self._prefetch_tracker_acceptable_submissions(trackers, attrgetter('source'), attrgetter('source_team')) + else: + trackers = [] return trackers diff --git a/tabbycat/adjfeedback/templates/enter_feedback.html b/tabbycat/adjfeedback/templates/enter_feedback.html index 23600a6ec2b..b71af255642 100644 --- a/tabbycat/adjfeedback/templates/enter_feedback.html +++ b/tabbycat/adjfeedback/templates/enter_feedback.html @@ -25,8 +25,10 @@ {% if source_type == 'team' %} {% if pref.feedback_from_teams == 'orallist' %} {% trans "This tournament expects you to submit feedback only on the adjudicator who delivered the adjudication. Do not submit feedback on other adjudicators." as message %} - {% else %} + {% elif pref.feedback_from_teams == 'all-adjs' %} {% trans "This tournament expects you to submit feedback on all of the adjudicators on the panel (including trainees)." as message %} + {% elif pref.feedback_from_teams == 'no-one' %} + {% trans "This tournament doesn't expect you to submit feedback on any of the adjudicators on the panel (including trainees)." as message %} {% endif %} {% include "components/alert.html" with type="info" %} {% endif %} diff --git a/tabbycat/adjfeedback/templates/feedback_overview.html b/tabbycat/adjfeedback/templates/feedback_overview.html index 758008d3f7d..df2d4f5a39f 100644 --- a/tabbycat/adjfeedback/templates/feedback_overview.html +++ b/tabbycat/adjfeedback/templates/feedback_overview.html @@ -22,6 +22,10 @@ {% blocktrans trimmed asvar p1 %} The current feedback configuration allows and expects all adjudicators (including trainees) to submit feedback on every other member of the panel (including trainees). {% endblocktrans %} + {% elif pref.feedback_paths == 'no-adjs' %} + {% blocktrans trimmed asvar p1 %} + The current feedback configuration disallows adjudicators to submit feedback on other members of the panel (including trainees). + {% endblocktrans %} {% endif %} {% include "components/explainer-card.html" with type="info" %} diff --git a/tabbycat/adjfeedback/utils.py b/tabbycat/adjfeedback/utils.py index 75f09b69f7a..c8ae224ebdb 100644 --- a/tabbycat/adjfeedback/utils.py +++ b/tabbycat/adjfeedback/utils.py @@ -55,7 +55,9 @@ def expected_feedback_targets(debateadj, feedback_paths=None, debate=None): debate = debateadj.debate adjudicators = debate.adjudicators - if feedback_paths == 'all-adjs' or debateadj.type == DebateAdjudicator.TYPE_CHAIR: + if feedback_paths == 'no-adjs': + targets = [] + elif feedback_paths == 'all-adjs' or debateadj.type == DebateAdjudicator.TYPE_CHAIR: targets = [(adj, pos) for adj, pos in adjudicators.with_positions() if adj.id != debateadj.adjudicator_id] elif feedback_paths == 'with-p-on-p' and debateadj.type == DebateAdjudicator.TYPE_PANEL: targets = [(adj, pos) for adj, pos in adjudicators.with_positions() if adj.id != debateadj.adjudicator_id and pos != AdjudicatorAllocation.POSITION_TRAINEE] diff --git a/tabbycat/adjfeedback/views.py b/tabbycat/adjfeedback/views.py index e8c8e31db8b..76313467738 100644 --- a/tabbycat/adjfeedback/views.py +++ b/tabbycat/adjfeedback/views.py @@ -503,7 +503,7 @@ def post(self, request, *args, **kwargs): class BaseTabroomAddFeedbackView(TabroomSubmissionFieldsMixin, BaseAddFeedbackView): """View for tabroom officials to add feedback.""" - action_log_type = ActionLogEntry.ACTION_TYPE_FEEDBACK_SAVE + action_log_type = ActionLogEntry.ActionType.FEEDBACK_SAVE feedback_form_class_kwargs = { 'confirm_on_submit': True, 'enforce_required': False, @@ -537,7 +537,7 @@ class AssistantAddFeedbackView(AssistantMixin, BaseTabroomAddFeedbackView): class PublicAddFeedbackView(PublicSubmissionFieldsMixin, PersonalizablePublicTournamentPageMixin, BaseAddFeedbackView): """Base class for views for public users to add feedback.""" - action_log_type = ActionLogEntry.ACTION_TYPE_FEEDBACK_SUBMIT + action_log_type = ActionLogEntry.ActionType.FEEDBACK_SUBMIT feedback_form_class_kwargs = { 'confirm_on_submit': True, 'enforce_required': True, @@ -648,7 +648,7 @@ def post(self, request, *args, **kwargs): class SetAdjudicatorBaseScoreView(BaseAdjudicatorActionView): - action_log_type = ActionLogEntry.ACTION_TYPE_TEST_SCORE_EDIT + action_log_type = ActionLogEntry.ActionType.TEST_SCORE_EDIT action_log_content_object_attr = 'atsh' edit_permission = Permission.EDIT_BASEJUDGESCORES_IND @@ -670,8 +670,8 @@ def modify_adjudicator(self, request, adjudicator): class SetAdjudicatorBreakingStatusView(AdministratorMixin, TournamentMixin, LogActionMixin, View): - action_log_type = ActionLogEntry.ACTION_TYPE_ADJUDICATOR_BREAK_SET edit_permission = Permission.EDIT_ADJ_BREAK + action_log_type = ActionLogEntry.ActionType.ADJUDICATOR_BREAK_SET def post(self, request, *args, **kwargs): body = self.request.body.decode('utf-8') @@ -794,8 +794,8 @@ def modify_feedback(self, feedback): class UpdateAdjudicatorScoresView(AdministratorMixin, LogActionMixin, TournamentMixin, FormView): template_name = 'update_adjudicator_scores.html' form_class = UpdateAdjudicatorScoresForm - action_log_type = ActionLogEntry.ACTION_TYPE_UPDATE_ADJUDICATOR_SCORES edit_permission = Permission.EDIT_JUDGESCORES_BULK + action_log_type = ActionLogEntry.ActionType.UPDATE_ADJUDICATOR_SCORES def get_context_data(self, **kwargs): sample_adjs = self.tournament.relevant_adjudicators.all()[:3] diff --git a/tabbycat/api/mixins.py b/tabbycat/api/mixins.py index 1c199c68118..b3e3ed83fa2 100644 --- a/tabbycat/api/mixins.py +++ b/tabbycat/api/mixins.py @@ -3,12 +3,29 @@ from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAdminUser +from actionlog.mixins import LogActionMixin +from actionlog.models import ActionLogEntry from tournaments.models import Round, Tournament from .permissions import APIEnabledPermission, IsAdminOrReadOnly, PerTournamentPermissionRequired, PublicIfReleasedPermission, PublicPreferencePermission -class TournamentAPIMixin: +class APILogActionMixin(LogActionMixin): + action_log_content_object_attr = 'obj' + + def perform_create(self, serializer): + self.obj = serializer.save(**self.lookup_kwargs()) + self.log_action(type=self.action_log_type_created, agent=ActionLogEntry.Agent.API) + + def perform_update(self, serializer): + self.obj = serializer.save() + self.log_action(type=self.action_log_type_updated, agent=ActionLogEntry.Agent.API) + + def lookup_kwargs(self): + return {} + + +class TournamentAPIMixin(APILogActionMixin): tournament_field = 'tournament' access_operator = operator.eq @@ -23,9 +40,6 @@ def tournament(self): def lookup_kwargs(self): return {self.tournament_field: self.tournament} - def perform_create(self, serializer): - serializer.save(**self.lookup_kwargs()) - def get_queryset(self): return self.get_serializer_class().Meta.model.objects.filter(**self.lookup_kwargs()).select_related(self.tournament_field) @@ -45,13 +59,8 @@ def round(self): self._round = get_object_or_404(Round, tournament=self.tournament, seq=self.kwargs['round_seq']) return self._round - def perform_create(self, serializer): - serializer.save(**{self.round_field: self.round}) - def lookup_kwargs(self): - kwargs = super().lookup_kwargs() - kwargs[self.round_field] = self.round - return kwargs + return {self.round_field: self.round} def get_serializer_context(self): context = super().get_serializer_context() diff --git a/tabbycat/api/permissions.py b/tabbycat/api/permissions.py index 4cbad113975..64212f1337b 100644 --- a/tabbycat/api/permissions.py +++ b/tabbycat/api/permissions.py @@ -41,14 +41,14 @@ def get_required_permission(self, view): codes that the user is required to have. """ return ({ - 'list': view.list_permission, - 'create': view.create_permission, - 'retrieve': view.list_permission, - 'update': view.update_permission, - 'partial_update': view.update_permission, - 'destroy': view.destroy_permission, - 'delete_all': view.destroy_permission, - 'add_blank': view.create_permission, + 'list': getattr(view, 'list_permission', False), + 'create': getattr(view, 'create_permission', False), + 'retrieve': getattr(view, 'list_permission', False), + 'update': getattr(view, 'update_permission', False), + 'partial_update': getattr(view, 'update_permission', False), + 'destroy': getattr(view, 'destroy_permission', False), + 'delete_all': getattr(view, 'destroy_permission', False), + 'add_blank': getattr(view, 'create_permission', False), }).get(view.action, False) def has_permission(self, request, view): diff --git a/tabbycat/api/serializers.py b/tabbycat/api/serializers.py index 733eff425a9..b985e36bd66 100644 --- a/tabbycat/api/serializers.py +++ b/tabbycat/api/serializers.py @@ -13,7 +13,7 @@ from rest_framework.settings import api_settings from adjallocation.models import DebateAdjudicator, PreformedPanel -from adjfeedback.models import AdjudicatorFeedback, AdjudicatorFeedbackQuestion +from adjfeedback.models import AdjudicatorBaseScoreHistory, AdjudicatorFeedback, AdjudicatorFeedbackQuestion from breakqual.models import BreakCategory, BreakingTeam from draw.models import Debate, DebateTeam from motions.models import DebateTeamMotionPreference, Motion, RoundMotion @@ -65,7 +65,7 @@ class V1LinksSerializer(serializers.Serializer): class CheckinSerializer(serializers.Serializer): object = serializers.HyperlinkedIdentityField(view_name='api-root') - barcode = serializers.IntegerField() + barcode = serializers.CharField() checked = serializers.BooleanField() timestamp = serializers.DateTimeField() @@ -149,10 +149,12 @@ class RoundMotionSerializer(serializers.ModelSerializer): text = serializers.CharField(source='motion.text', max_length=500, required=False) reference = serializers.CharField(source='motion.reference', max_length=100, required=False) info_slide = serializers.CharField(source='motion.info_slide', required=False) + info_slide_plain = serializers.CharField(source='motion.info_slide_plain', read_only=True) + seq = serializers.IntegerField(read_only=True) class Meta: model = RoundMotion - exclude = ('round', 'motion', 'seq') + exclude = ('round', 'motion') def create(self, validated_data): motion_data = validated_data.pop('motion') @@ -173,6 +175,8 @@ class RoundLinksSerializer(serializers.Serializer): pairing = fields.TournamentHyperlinkedIdentityField( view_name='api-pairing-list', lookup_field='seq', lookup_url_kwarg='round_seq') + availabilities = fields.TournamentHyperlinkedIdentityField(view_name='api-availability-list', lookup_field='seq', lookup_url_kwarg='round_seq') + preformed_panels = fields.TournamentHyperlinkedIdentityField(view_name='api-preformedpanel-list', lookup_field='seq', lookup_url_kwarg='round_seq') url = fields.TournamentHyperlinkedIdentityField( view_name='api-round-detail', @@ -212,7 +216,7 @@ def validate(self, data): def create(self, validated_data): motions_data = validated_data.pop('roundmotion_set', []) - if len(motions_data) > 0 and not has_permission(self.context['request'].user, Permission.EDIT_MOTION): + if len(motions_data) > 0 and not has_permission(self.context['request'].user, Permission.EDIT_MOTION, self.context['tournament']): raise serializers.PermissionDenied('Editing motions disallowed') round = super().create(validated_data) @@ -229,7 +233,7 @@ def create(self, validated_data): def update(self, instance, validated_data): motions_data = validated_data.pop('roundmotion_set', []) - if len(motions_data) > 0 and not has_permission(self.context['request'].user, Permission.EDIT_MOTION): + if len(motions_data) > 0 and not has_permission(self.context['request'].user, Permission.EDIT_MOTION, self.context['tournament']): raise serializers.PermissionDenied('Editing motions disallowed') for i, roundmotion in enumerate(motions_data, start=1): roundmotion['seq'] = i @@ -328,6 +332,7 @@ class Meta: url = fields.TournamentHyperlinkedIdentityField(view_name='api-motion-detail') rounds = RoundsSerializer(many=True, source='roundmotion_set') + info_slide_plain = serializers.CharField(read_only=True) class Meta: model = Motion @@ -449,8 +454,14 @@ class Meta: model = BreakingTeam fields = ('team', 'remark') + def validate_team(self, value): + try: + return self.context['break_category'].breakingteam_set.get(team=value) + except BreakingTeam.DoesNotExist: + raise serializers.ValidationError('Team is not included in break') + def save(self, **kwargs): - bt = self.context['break_category'].breakingteam_set.get(team=self.validated_data['team']) + bt = self.validated_data['team'] bt.remark = self.validated_data.get('remark', '') bt.save() return bt @@ -470,6 +481,7 @@ class SpeakerLinksSerializer(serializers.Serializer): queryset=SpeakerCategory.objects.all(), ) _links = SpeakerLinksSerializer(source='*', read_only=True) + barcode = serializers.CharField(source='checkin_identifier.barcode', read_only=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -484,6 +496,8 @@ def __init__(self, *args, **kwargs): self.fields.pop('pronoun') if not with_permission(Permission.VIEW_PRIVATE_URLS): self.fields.pop('url_key') + if not with_permission(Permission.VIEW_CHECKIN): + self.fields.pop('barcode') if not with_permission(Permission.VIEW_PARTICIPANT_DECODED) and t.pref('participant_code_names') == 'everywhere': self.fields.pop('name') @@ -538,6 +552,7 @@ class AdjudicatorLinksSerializer(serializers.Serializer): ) venue_constraints = VenueConstraintSerializer(many=True, required=False) _links = AdjudicatorLinksSerializer(source='*', read_only=True) + barcode = serializers.CharField(source='checkin_identifier.barcode', read_only=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -575,6 +590,8 @@ def __init__(self, *args, **kwargs): self.fields.pop('pronoun') if not with_permission(Permission.VIEW_PRIVATE_URLS): self.fields.pop('url_key') + if not with_permission(Permission.VIEW_CHECKIN): + self.fields.pop('barcode') class Meta: model = Adjudicator @@ -611,6 +628,10 @@ def update(self, instance, validated_data): vc._validated_data = venue_constraints # Data was already validated vc.save(adjudicator=instance) + if 'base_score' in validated_data and validated_data['base_score'] != instance.base_score: + AdjudicatorBaseScoreHistory.objects.create( + adjudicator=instance, round=self.context['tournament'].current_round, score=validated_data['base_score']) + if self.partial: # Avoid removing conflicts if merely PATCHing for field in ['institution_conflicts', 'adjudicator_conflicts', 'team_conflicts']: @@ -930,12 +951,19 @@ class Meta: model = DebateTeam fields = ('team', 'side') + class PairingLinksSerializer(serializers.Serializer): + ballots = fields.RoundHyperlinkedIdentityField( + view_name='api-ballot-list', + lookup_field='pk', lookup_url_kwarg='debate_pk') + url = fields.RoundHyperlinkedIdentityField(view_name='api-pairing-detail', lookup_url_kwarg='debate_pk') venue = fields.TournamentHyperlinkedRelatedField(view_name='api-venue-detail', queryset=Venue.objects.all(), required=False, allow_null=True) teams = DebateTeamSerializer(many=True, source='debateteam_set') adjudicators = DebateAdjudicatorSerializer(required=False, allow_null=True) + _links = PairingLinksSerializer(source='*', read_only=True) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not is_staff(kwargs.get('context')): diff --git a/tabbycat/api/tests/test_serializers.py b/tabbycat/api/tests/test_serializers.py index ee8fa7267c4..9f92e320e4d 100644 --- a/tabbycat/api/tests/test_serializers.py +++ b/tabbycat/api/tests/test_serializers.py @@ -181,6 +181,7 @@ def setUp(self): def tearDown(self): self.debate.delete() + self.tournament.actionlogentry_set.all().delete() self.tournament.delete() self.user.delete() logging.disable(logging.NOTSET) diff --git a/tabbycat/api/tests/test_views.py b/tabbycat/api/tests/test_views.py index 96ea48589b8..8d181f2bfb2 100644 --- a/tabbycat/api/tests/test_views.py +++ b/tabbycat/api/tests/test_views.py @@ -3,6 +3,7 @@ from dynamic_preferences.registries import global_preferences_registry from rest_framework.test import APITestCase +from breakqual.models import BreakingTeam from utils.tests import CompletedTournamentTestMixin @@ -87,3 +88,54 @@ def test_all_categories_authenticated(self): self.client.login(username="admin", password="admin") response = self.client.get(reverse('api-speakercategory-list', kwargs={'tournament_slug': self.tournament.slug})) self.assertEqual(len(response.data), 2) + + +class BreakEligibilityViewsetTests(CompletedTournamentTestMixin, APITestCase): + + def test_get_eligible_teams(self): + self.client.login(username="admin", password="admin") + response = self.client.get(reverse('api-breakcategory-eligibility', kwargs={'tournament_slug': self.tournament.slug, 'pk': 1})) + self.assertEqual(len(response.data), 2) + + +class SpeakerEligibilityViewsetTests(CompletedTournamentTestMixin, APITestCase): + + def setUp(self): + super().setUp() + self.sc = self.tournament.speakercategory_set.create(name='sc1', slug='sc1', seq=1, public=False) + self.sc.speaker_set.set(self.tournament.team_set.first().speaker_set.all()) + + def test_get_eligible_speakers(self): + self.client.login(username="admin", password="admin") + response = self.client.get(reverse('api-speakercategory-eligibility', kwargs={'tournament_slug': self.tournament.slug, 'pk': self.sc.pk})) + self.assertEqual(len(response.data), 2) + + def test_unauthorized_if_private(self): + response = self.client.get(reverse('api-speakercategory-eligibility', kwargs={'tournament_slug': self.tournament.slug, 'pk': self.sc.pk})) + self.assertEqual(response.status_code, 401) + + +class BreakingTeamsViewsetTests(CompletedTournamentTestMixin, APITestCase): + + def test_get_breaking_teams(self): + self.client.login(username="admin", password="admin") + response = self.client.get(reverse('api-breakcategory-break', kwargs={'tournament_slug': self.tournament.slug, 'pk': 1})) + self.assertEqual(len(response.data), 8) + + def test_generate_break(self): + self.client.login(username="admin", password="admin") + response = self.client.post(reverse('api-breakcategory-break', kwargs={'tournament_slug': self.tournament.slug, 'pk': 1})) + self.assertEqual(len(response.data), 13) + + def test_delete_break(self): + self.client.login(username="admin", password="admin") + response = self.client.delete(reverse('api-breakcategory-break', kwargs={'tournament_slug': self.tournament.slug, 'pk': 1})) + self.assertEqual(response.status_code, 204) + + def test_remove_breaking_team(self): + self.client.login(username="admin", password="admin") + response = self.client.patch(reverse('api-breakcategory-break', kwargs={'tournament_slug': self.tournament.slug, 'pk': 1}), { + 'team': 'http://testserver/api/v1/tournaments/demo/teams/7', + 'remark': BreakingTeam.REMARK_WITHDRAWN, + }, content_type='application/json') + self.assertEqual(len(response.data), 16) diff --git a/tabbycat/api/views.py b/tabbycat/api/views.py index e2e92ea667c..0a302cd23f2 100644 --- a/tabbycat/api/views.py +++ b/tabbycat/api/views.py @@ -20,6 +20,7 @@ from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet +from actionlog.models import ActionLogEntry from adjallocation.models import PreformedPanel from adjallocation.preformed.anticipated import calculate_anticipated_draw from adjfeedback.models import AdjudicatorFeedbackQuestion @@ -42,7 +43,7 @@ from . import serializers from .fields import ParticipantAvailabilityForeignKeyField -from .mixins import AdministratorAPIMixin, PublicAPIMixin, RoundAPIMixin, TournamentAPIMixin, TournamentPublicAPIMixin +from .mixins import AdministratorAPIMixin, APILogActionMixin, PublicAPIMixin, RoundAPIMixin, TournamentAPIMixin, TournamentPublicAPIMixin from .permissions import APIEnabledPermission, PerTournamentPermissionRequired, PublicPreferencePermission @@ -104,7 +105,7 @@ def get(self, request, format=None): partial_update=extend_schema(summary="Patch tournament", parameters=[tournament_parameter]), destroy=extend_schema(summary="Delete tournament", parameters=[tournament_parameter]), ) -class TournamentViewSet(PublicAPIMixin, ModelViewSet): +class TournamentViewSet(PublicAPIMixin, APILogActionMixin, ModelViewSet): # Don't use TournamentAPIMixin here, it's not filtering objects by tournament. queryset = Tournament.objects.all().prefetch_related( 'breakcategory_set', @@ -115,6 +116,8 @@ class TournamentViewSet(PublicAPIMixin, ModelViewSet): serializer_class = serializers.TournamentSerializer lookup_field = 'slug' lookup_url_kwarg = 'tournament_slug' + action_log_type_created = ActionLogEntry.ActionType.TOURNAMENT_CREATE + action_log_type_updated = ActionLogEntry.ActionType.TOURNAMENT_EDIT @extend_schema(tags=['tournaments'], parameters=[tournament_parameter]) @@ -125,7 +128,7 @@ class TournamentViewSet(PublicAPIMixin, ModelViewSet): partial_update=extend_schema(summary="Patch tournament preference"), bulk=extend_schema(summary="Update multiple tournament preferences"), ) -class TournamentPreferenceViewSet(TournamentFromUrlMixin, AdministratorAPIMixin, PerInstancePreferenceViewSet): +class TournamentPreferenceViewSet(TournamentFromUrlMixin, AdministratorAPIMixin, APILogActionMixin, PerInstancePreferenceViewSet): """ """ # Blank comment to avoid comment from TournamentFromUrlMixin appearing. @@ -135,6 +138,9 @@ class TournamentPreferenceViewSet(TournamentFromUrlMixin, AdministratorAPIMixin, list_permission = Permission.VIEW_TOURNAMENTPREFERENCEMODEL update_permission = Permission.EDIT_TOURNAMENTPREFERENCEMODEL + action_log_content_object_attr = 'obj' + action_log_type_updated = ActionLogEntry.ActionType.OPTIONS_EDIT + def get_related_instance(self): return self.tournament @@ -152,6 +158,8 @@ class RoundViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.RoundSerializer lookup_field = 'seq' lookup_url_kwarg = 'round_seq' + action_log_type_created = ActionLogEntry.ActionType.ROUND_CREATE + action_log_type_updated = ActionLogEntry.ActionType.ROUND_EDIT create_permission = Permission.CREATE_ROUND update_permission = Permission.EDIT_ROUND @@ -176,6 +184,8 @@ class MotionViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet): serializer_class = serializers.MotionSerializer access_preference = ('public_motions', 'motion_tab_released') access_operator = any + action_log_type_created = ActionLogEntry.ActionType.MOTION_EDIT + action_log_type_updated = ActionLogEntry.ActionType.MOTION_EDIT list_permission = Permission.VIEW_MOTION create_permission = Permission.EDIT_MOTION @@ -200,6 +210,8 @@ def get_queryset(self): ) class BreakCategoryViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.BreakCategorySerializer + action_log_type_created = ActionLogEntry.ActionType.BREAK_CATEGORIES_EDIT + action_log_type_updated = ActionLogEntry.ActionType.BREAK_CATEGORIES_EDIT list_permission = Permission.VIEW_BREAK_CATEGORIES create_permission = Permission.EDIT_BREAK_CATEGORIES @@ -218,6 +230,8 @@ class BreakCategoryViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): ) class SpeakerCategoryViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.SpeakerCategorySerializer + action_log_type_created = ActionLogEntry.ActionType.SPEAKER_CATEGORIES_EDIT + action_log_type_updated = ActionLogEntry.ActionType.SPEAKER_CATEGORIES_EDIT list_permission = Permission.VIEW_SPEAKER_CATEGORIES create_permission = Permission.EDIT_SPEAKER_CATEGORIES @@ -239,6 +253,7 @@ def get_queryset(self): class BreakEligibilityView(TournamentAPIMixin, TournamentPublicAPIMixin, RetrieveUpdateAPIView): serializer_class = serializers.BreakEligibilitySerializer access_preference = 'public_break_categories' + action_log_type_updated = ActionLogEntry.ActionType.BREAK_ELIGIBILITY_EDIT list_permission = Permission.VIEW_BREAK_ELIGIBILITY create_permission = Permission.EDIT_BREAK_ELIGIBILITY @@ -257,6 +272,7 @@ def get_queryset(self): class SpeakerEligibilityView(TournamentAPIMixin, TournamentPublicAPIMixin, RetrieveUpdateAPIView): serializer_class = serializers.SpeakerEligibilitySerializer access_preference = 'public_participants' + action_log_type_updated = ActionLogEntry.ActionType.SPEAKER_ELIGIBILITY_EDIT list_permission = Permission.VIEW_SPEAKER_ELIGIBILITY create_permission = Permission.EDIT_SPEAKER_ELIGIBILITY @@ -276,6 +292,7 @@ class BreakingTeamsView(TournamentAPIMixin, TournamentPublicAPIMixin, GenerateBr tournament_field = 'break_category__tournament' pagination_class = None access_preference = 'public_breaking_teams' + action_log_content_object_attr = 'break_category' list_permission = Permission.VIEW_BREAK create_permission = Permission.GENERATE_BREAK @@ -300,6 +317,7 @@ def get_serializer_context(self): @extend_schema(summary="Generate break") def create(self, request, *args, **kwargs): self.generate_break((self.break_category,)) + self.log_action(type=ActionLogEntry.ActionType.BREAK_GENERATE_ONE) return self.list(request, *args, **kwargs) @extend_schema(summary="Delete break") @@ -308,6 +326,7 @@ def destroy(self, request, *args, **kwargs): Destroy is normally for a specific instance, now QuerySet. """ self.filter_queryset(self.get_queryset()).delete() + self.log_action(type=ActionLogEntry.ActionType.BREAK_DELETE) return Response(status=204) # No content @extend_schema(summary="Update remark and regenerate break") @@ -315,6 +334,7 @@ def update(self, request, *args, **kwargs): serializer = serializers.PartialBreakingTeamSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) serializer.save() + self.log_action(type=ActionLogEntry.ActionType.BREAK_UPDATE_ONE) return self.create(request, *args, **kwargs) @@ -328,6 +348,8 @@ def update(self, request, *args, **kwargs): class InstitutionViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet): serializer_class = serializers.PerTournamentInstitutionSerializer access_preference = 'public_institutions_list' + action_log_type_created = ActionLogEntry.ActionType.INSTITUTION_CREATE + action_log_type_updated = ActionLogEntry.ActionType.INSTITUTION_EDIT list_permission = Permission.VIEW_INSTITUTIONS create_permission = Permission.ADD_INSTITUTIONS @@ -336,6 +358,7 @@ class InstitutionViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelView def perform_create(self, serializer): serializer.save() + self.log_action(type=self.action_log_type_created) def get_queryset(self): filters = Q() @@ -364,6 +387,8 @@ def get_queryset(self): class TeamViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet): serializer_class = serializers.TeamSerializer access_preference = 'public_participants' + action_log_type_created = ActionLogEntry.ActionType.TEAM_CREATE + action_log_type_updated = ActionLogEntry.ActionType.TEAM_EDIT list_permission = Permission.VIEW_TEAMS create_permission = Permission.ADD_TEAMS @@ -378,7 +403,7 @@ def get_queryset(self): return super().get_queryset().select_related('tournament').prefetch_related( Prefetch( 'speaker_set', - queryset=Speaker.objects.all().prefetch_related(category_prefetch).select_related('team__tournament'), + queryset=Speaker.objects.all().prefetch_related(category_prefetch).select_related('team__tournament', 'checkin_identifier'), ), 'institution_conflicts', 'venue_constraints__category__tournament', 'break_categories', 'break_categories__tournament', @@ -399,6 +424,8 @@ def get_queryset(self): class AdjudicatorViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet): serializer_class = serializers.AdjudicatorSerializer access_preference = 'public_participants' + action_log_type_created = ActionLogEntry.ActionType.ADJUDICATOR_CREATE + action_log_type_updated = ActionLogEntry.ActionType.ADJUDICATOR_EDIT list_permission = Permission.VIEW_ADJUDICATORS create_permission = Permission.ADD_ADJUDICATORS @@ -413,7 +440,7 @@ def get_queryset(self): if self.request.query_params.get('break') and self.get_break_permission(): filters &= Q(breaking=True) - return super().get_queryset().prefetch_related( + return super().get_queryset().select_related('checkin_identifier').prefetch_related( 'team_conflicts', 'team_conflicts__tournament', 'adjudicator_conflicts', 'adjudicator_conflicts__tournament', 'institution_conflicts', 'venue_constraints__category__tournament', @@ -433,6 +460,8 @@ def get_queryset(self): ) class GlobalInstitutionViewSet(AdministratorAPIMixin, ModelViewSet): serializer_class = serializers.InstitutionSerializer + action_log_type_created = ActionLogEntry.ActionType.INSTITUTION_CREATE + action_log_type_updated = ActionLogEntry.ActionType.INSTITUTION_EDIT list_permission = Permission.VIEW_INSTITUTIONS create_permission = Permission.ADD_INSTITUTIONS @@ -459,6 +488,8 @@ class SpeakerViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet) serializer_class = serializers.SpeakerSerializer tournament_field = "team__tournament" access_preference = 'public_participants' + action_log_type_created = ActionLogEntry.ActionType.SPEAKER_CREATE + action_log_type_updated = ActionLogEntry.ActionType.SPEAKER_EDIT list_permission = Permission.VIEW_TEAMS create_permission = Permission.ADD_TEAMS @@ -467,13 +498,14 @@ class SpeakerViewSet(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet) def perform_create(self, serializer): serializer.save() + self.log_action(type=self.action_log_type_created) def get_queryset(self): category_prefetch = Prefetch('categories', queryset=SpeakerCategory.objects.all().select_related('tournament')) if not self.request.user or not self.request.user.is_staff: category_prefetch.queryset = category_prefetch.queryset.filter(public=True) - return super().get_queryset().prefetch_related(category_prefetch) + return super().get_queryset().select_related('checkin_identifier').prefetch_related(category_prefetch) @extend_schema(tags=['venues'], parameters=[tournament_parameter]) @@ -487,6 +519,8 @@ def get_queryset(self): ) class VenueViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.VenueSerializer + action_log_type_created = ActionLogEntry.ActionType.VENUE_CREATE + action_log_type_updated = ActionLogEntry.ActionType.VENUE_EDIT list_permission = Permission.VIEW_ROOMS create_permission = Permission.ADD_ROOMS @@ -510,6 +544,8 @@ def get_queryset(self): ) class VenueCategoryViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.VenueCategorySerializer + action_log_type_created = ActionLogEntry.ActionType.VENUE_CATEGORY_CREATE + action_log_type_updated = ActionLogEntry.ActionType.VENUE_CATEGORIES_EDIT list_permission = Permission.VIEW_ROOMCATEGORIES create_permission = Permission.EDIT_ROOMCATEGORIES @@ -840,9 +876,6 @@ class TeamRoundStandingsRoundsView(TournamentAPIMixin, TournamentPublicAPIMixin, list_permission = Permission.VIEW_TEAMSTANDINGS - def perform_create(self, serializer): - serializer.save() - def get_queryset(self): ts_pf = Prefetch('teamscore_set', queryset=TeamScore.objects.filter(ballot_submission__confirmed=True), to_attr='round_scores') qs = super().get_queryset().prefetch_related( @@ -902,6 +935,9 @@ def get_round_status(self, view): permission_classes = [APIEnabledPermission, Permission | PerTournamentPermissionRequired] + action_log_type_created = ActionLogEntry.ActionType.DEBATE_CREATE + action_log_type_updated = ActionLogEntry.ActionType.DEBATE_EDIT + def get_queryset(self): return super().get_queryset().select_related('round', 'round__tournament', 'venue', 'venue__tournament').prefetch_related( 'debateteam_set', 'debateteam_set__team', 'debateteam_set__team__tournament', @@ -911,6 +947,7 @@ def get_queryset(self): @extend_schema(summary="Delete all pairings in the round") def delete_all(self, request, *args, **kwargs): self.get_queryset().delete() + self.log_action(ActionLogEntry.ActionType.DRAW_REGENERATE) return Response(status=204) # No content @@ -936,6 +973,9 @@ class BallotViewSet(RoundAPIMixin, TournamentPublicAPIMixin, ModelViewSet): update_permission = Permission.EDIT_BALLOTSUBMISSIONS destroy_permission = Permission.MARK_BALLOTSUBMISSIONS + action_log_type_created = ActionLogEntry.ActionType.BALLOT_CREATE + action_log_type_updated = ActionLogEntry.ActionType.BALLOT_EDIT + @property def debate(self): if hasattr(self, '_debate'): @@ -944,13 +984,8 @@ def debate(self): self._debate = get_object_or_404(Debate, pk=self.kwargs.get('debate_pk')) return self._debate - def perform_create(self, serializer): - serializer.save(**{'debate': self.debate}) - def lookup_kwargs(self): - kwargs = super().lookup_kwargs() - kwargs['debate'] = self.debate - return kwargs + return {'debate': self.debate} def get_serializer_context(self): context = super().get_serializer_context() @@ -974,6 +1009,7 @@ def destroy(self, request, *args, **kwargs): instance = self.get_object() instance.discarded = True instance.save() + self.log_action(ActionLogEntry.ActionType.BALLOT_DISCARD) return self.retrieve(request, *args, **kwargs) @@ -991,6 +1027,8 @@ def destroy(self, request, *args, **kwargs): ) class FeedbackQuestionViewSet(TournamentAPIMixin, PublicAPIMixin, ModelViewSet): serializer_class = serializers.FeedbackQuestionSerializer + action_log_type_created = ActionLogEntry.ActionType.FEEDBACK_QUESTION_CREATE + action_log_type_updated = ActionLogEntry.ActionType.FEEDBACK_QUESTION_EDIT list_permission = True create_permission = Permission.EDIT_FEEDBACKQUESTION @@ -1023,6 +1061,8 @@ def get_queryset(self): class FeedbackViewSet(TournamentAPIMixin, AdministratorAPIMixin, ModelViewSet): serializer_class = serializers.FeedbackSerializer tournament_field = 'adjudicator__tournament' + action_log_type_created = ActionLogEntry.ActionType.FEEDBACK_SAVE + action_log_type_updated = ActionLogEntry.ActionType.FEEDBACK_SAVE list_permission = Permission.VIEW_FEEDBACK create_permission = Permission.ADD_FEEDBACK @@ -1070,6 +1110,7 @@ def get_queryset(self): @extend_schema(tags=['availabilities'], parameters=round_parameters) class AvailabilitiesViewSet(RoundAPIMixin, AdministratorAPIMixin, APIView): serializer_class = serializers.AvailabilitiesSerializer # Isn't actually used + action_log_type_updated = ActionLogEntry.ActionType.AVAIL_SAVE list_permission = Permission.VIEW_ROUNDAVAILABILITIES create_permission = Permission.EDIT_ROUNDAVAILABILITIES @@ -1121,6 +1162,7 @@ def patch(self, request, *args, **kwargs): RoundAvailability.objects.bulk_create( [RoundAvailability(content_type=contenttype, round=self.round, object_id=id) for id in ids - existing]) + self.log_action(type=self.action_log_type_updated) return self.get(request, *args, **kwargs) @@ -1131,6 +1173,7 @@ def put(self, request, *args, **kwargs): contenttype = ContentType.objects.get_for_model(model) RoundAvailability.objects.bulk_create( [RoundAvailability(content_type=contenttype, round=self.round, object_id=p.id) for p in participants]) + self.log_action(type=self.action_log_type_updated) return self.get(request, *args, **kwargs) @extend_schema(summary="Mark objects as unavailable") @@ -1142,11 +1185,13 @@ def post(self, request, *args, **kwargs): content_type=contenttype, round=self.round, object_id__in=[p.id for p in participants], ).delete() + self.log_action(type=self.action_log_type_updated) return self.get(request, *args, **kwargs) @extend_schema(summary="Delete class of availabilities", parameters=extra_params) def delete(self, request, *args, **kwargs): self.get_queryset().delete() + self.log_action(type=self.action_log_type_updated) return Response(status=204) @@ -1163,6 +1208,8 @@ class PreformedPanelViewSet(RoundAPIMixin, AdministratorAPIMixin, ModelViewSet): serializer_class = serializers.PreformedPanelSerializer lookup_url_kwarg = 'debate_pk' + action_log_type_created = ActionLogEntry.ActionType.PREFORMED_PANELS_CREATE + action_log_type_updated = ActionLogEntry.ActionType.PREFORMED_PANELS_ADJUDICATOR_EDIT list_permission = Permission.VIEW_PREFORMEDPANELS create_permission = Permission.EDIT_PREFORMEDPANELS @@ -1177,13 +1224,8 @@ def debate(self): self._debate = get_object_or_404(PreformedPanel, pk=self.kwargs.get('debate_pk')) return self._debate - def perform_create(self, serializer): - serializer.save(**{'debate': self.debate}) - def lookup_kwargs(self): - kwargs = super().lookup_kwargs() - kwargs['debate'] = self.debate - return kwargs + return {'debate': self.debate} def get_serializer_context(self): context = super().get_serializer_context() @@ -1200,6 +1242,7 @@ def get_queryset(self): @extend_schema(summary="Delete all preformed panels from round") def delete_all(self, request, *args, **kwargs): self.get_queryset().delete() + self.log_action(ActionLogEntry.ActionType.PREFORMED_PANELS_DELETE) return Response(status=204) # No content @extend_schema(summary="Add blank preformed panels") @@ -1211,6 +1254,7 @@ def add_blank(self, request, *args, **kwargs): 'bracket_min': bracket_min, 'liveness': liveness, }) + self.log_action(self.action_log_type_created) return self.get(request, *args, **kwargs) diff --git a/tabbycat/availability/views.py b/tabbycat/availability/views.py index 9d804f6ab57..2601760a9be 100644 --- a/tabbycat/availability/views.py +++ b/tabbycat/availability/views.py @@ -373,15 +373,15 @@ def post(self, request, *args, **kwargs): class UpdateAdjudicatorsAvailabilityView(BaseAvailabilityUpdateView): - action_log_type = ActionLogEntry.ACTION_TYPE_AVAIL_ADJUDICATORS_SAVE + action_log_type = ActionLogEntry.ActionType.AVAIL_ADJUDICATORS_SAVE model = Adjudicator class UpdateTeamsAvailabilityView(BaseAvailabilityUpdateView): - action_log_type = ActionLogEntry.ACTION_TYPE_AVAIL_TEAMS_SAVE + action_log_type = ActionLogEntry.ActionType.AVAIL_TEAMS_SAVE model = Team class UpdateVenuesAvailabilityView(BaseAvailabilityUpdateView): - action_log_type = ActionLogEntry.ACTION_TYPE_AVAIL_VENUES_SAVE + action_log_type = ActionLogEntry.ActionType.AVAIL_VENUES_SAVE model = Venue diff --git a/tabbycat/breakqual/views.py b/tabbycat/breakqual/views.py index 9464b8bdfff..5f66acf6f6e 100644 --- a/tabbycat/breakqual/views.py +++ b/tabbycat/breakqual/views.py @@ -116,11 +116,11 @@ class BreakingTeamsFormView(GenerateBreakMixin, LogActionMixin, AdministratorMix def get_action_log_type(self): if 'save_update_all' in self.request.POST: - return ActionLogEntry.ACTION_TYPE_BREAK_UPDATE_ALL + return ActionLogEntry.ActionType.BREAK_UPDATE_ALL elif 'save_update_one' in self.request.POST: - return ActionLogEntry.ACTION_TYPE_BREAK_UPDATE_ONE + return ActionLogEntry.ActionType.BREAK_UPDATE_ONE else: - return ActionLogEntry.ACTION_TYPE_BREAK_EDIT_REMARKS + return ActionLogEntry.ActionType.BREAK_EDIT_REMARKS def get_success_url(self): return reverse_tournament('breakqual-teams', self.tournament, kwargs={'category': self.object.slug}) @@ -190,7 +190,7 @@ def post(self, request, *args, **kwargs): class GenerateAllBreaksView(GenerateBreakMixin, LogActionMixin, TournamentMixin, AdministratorMixin, PostOnlyRedirectView): - action_log_type = ActionLogEntry.ACTION_TYPE_BREAK_GENERATE_ALL + action_log_type = ActionLogEntry.ActionType.BREAK_GENERATE_ALL tournament_redirect_pattern_name = 'breakqual-teams' edit_permission = Permission.GENERATE_BREAK @@ -259,9 +259,9 @@ class EditBreakCategoriesView(EditSpeakerCategoriesView): template_name = 'break_categories_edit.html' formset_model = BreakCategory - action_log_type = ActionLogEntry.ACTION_TYPE_BREAK_CATEGORIES_EDIT view_permission = Permission.VIEW_BREAK_CATEGORIES edit_permission = Permission.EDIT_BREAK_CATEGORIES + action_log_type = ActionLogEntry.ActionType.BREAK_CATEGORIES_EDIT url_name = 'break-categories-edit' success_url = 'breakqual-index' @@ -277,7 +277,10 @@ def get_formset_factory_kwargs(self): } def get_formset_kwargs(self): - return {'form_kwargs': {'tournament': self.tournament}} + return { + 'initial': [{'tournament': self.tournament}] * 2, + 'form_kwargs': {'tournament': self.tournament}, + } def prepare_related(self, cat): auto_make_break_rounds(cat, prefix=True) @@ -338,7 +341,7 @@ def get_context_data(self, **kwargs): class UpdateEligibilityEditView(BaseUpdateEligibilityEditView): - action_log_type = ActionLogEntry.ACTION_TYPE_BREAK_ELIGIBILITY_EDIT + action_log_type = ActionLogEntry.ActionType.BREAK_ELIGIBILITY_EDIT participant_model = Team many_to_many_field = 'break_categories' edit_permission = Permission.EDIT_BREAK_ELIGIBILITY diff --git a/tabbycat/checkins/views.py b/tabbycat/checkins/views.py index a58da0fb876..c7ad269f185 100644 --- a/tabbycat/checkins/views.py +++ b/tabbycat/checkins/views.py @@ -213,11 +213,11 @@ class AdminCheckInGenerateView(AdministratorMixin, LogActionMixin, def get_action_log_type(self): if self.kwargs["kind"] == "speakers": - return ActionLogEntry.ACTION_TYPE_CHECKIN_SPEAK_GENERATE + return ActionLogEntry.ActionType.CHECKIN_SPEAK_GENERATE elif self.kwargs["kind"] == "adjudicators": - return ActionLogEntry.ACTION_TYPE_CHECKIN_ADJ_GENERATE + return ActionLogEntry.ActionType.CHECKIN_ADJ_GENERATE elif self.kwargs["kind"] == "venues": - return ActionLogEntry.ACTION_TYPE_CHECKIN_VENUES_GENERATE + return ActionLogEntry.ActionType.CHECKIN_VENUES_GENERATE # Providing tournament_slug_url_kwarg isn't working for some reason; so use: def get_redirect_url(self, *args, **kwargs): diff --git a/tabbycat/draw/manager.py b/tabbycat/draw/manager.py index 71b7565e904..093416bbd8e 100644 --- a/tabbycat/draw/manager.py +++ b/tabbycat/draw/manager.py @@ -200,7 +200,7 @@ def create(self): pairings = drawer.generate() self._make_debates(pairings) - self._make_bye_debates(byes, max([p.room_rank for p in pairings])) + self._make_bye_debates(byes, max([p.room_rank for p in pairings], default=0)) self.round.draw_status = Round.Status.DRAFT self.round.save() diff --git a/tabbycat/draw/templates/draw_display_by.html b/tabbycat/draw/templates/draw_display_by.html index 448efc365e6..2da69f50111 100644 --- a/tabbycat/draw/templates/draw_display_by.html +++ b/tabbycat/draw/templates/draw_display_by.html @@ -94,6 +94,7 @@ $('#stop_scrolling').hide(); $(".scroll-speeds .btn").click(function(event){ var speed = $(document).height() / $(this).attr('data-target'); + stopScrolling() startScrolling() $('html, body').animate( { scrollTop: $(document).height() - $(window).height()}, diff --git a/tabbycat/draw/views.py b/tabbycat/draw/views.py index 0ec9acd5469..dbf3cc8438f 100644 --- a/tabbycat/draw/views.py +++ b/tabbycat/draw/views.py @@ -661,7 +661,7 @@ class DrawStatusEdit(LogActionMixin, AdministratorMixin, RoundMixin, PostOnlyRed class CreateDrawView(DrawStatusEdit): edit_permission = Permission.GENERATE_DEBATE - action_log_type = ActionLogEntry.ACTION_TYPE_DRAW_CREATE + action_log_type = ActionLogEntry.ActionType.DRAW_CREATE def post(self, request, *args, **kwargs): if self.round.draw_status != Round.Status.NONE: @@ -714,7 +714,7 @@ def post(self, request, *args, **kwargs): class ConfirmDrawCreationView(DrawStatusEdit): - action_log_type = ActionLogEntry.ACTION_TYPE_DRAW_CONFIRM + action_log_type = ActionLogEntry.ActionType.DRAW_CONFIRM def post(self, request, *args, **kwargs): if self.round.draw_status != Round.Status.DRAFT: @@ -731,7 +731,7 @@ def post(self, request, *args, **kwargs): class DrawRegenerateView(DrawStatusEdit): - action_log_type = ActionLogEntry.ACTION_TYPE_DRAW_REGENERATE + action_log_type = ActionLogEntry.ActionType.DRAW_REGENERATE round_redirect_pattern_name = 'availability-index' def post(self, request, *args, **kwargs): @@ -748,7 +748,7 @@ class ConfirmDrawRegenerationView(AdministratorMixin, TemplateView): class DrawReleaseView(DrawStatusEdit): edit_permission = Permission.RELEASE_DRAW - action_log_type = ActionLogEntry.ACTION_TYPE_DRAW_RELEASE + action_log_type = ActionLogEntry.ActionType.DRAW_RELEASE round_redirect_pattern_name = 'draw-display' def post(self, request, *args, **kwargs): @@ -769,7 +769,7 @@ def post(self, request, *args, **kwargs): class DrawUnreleaseView(DrawStatusEdit): edit_permission = Permission.UNRELEASE_DRAW - action_log_type = ActionLogEntry.ACTION_TYPE_DRAW_UNRELEASE + action_log_type = ActionLogEntry.ActionType.DRAW_UNRELEASE round_redirect_pattern_name = 'draw-display' def post(self, request, *args, **kwargs): @@ -786,7 +786,7 @@ def post(self, request, *args, **kwargs): class SetRoundStartTimeView(DrawStatusEdit): edit_permission = Permission.EDIT_STARTTIME - action_log_type = ActionLogEntry.ACTION_TYPE_ROUND_START_TIME_SET + action_log_type = ActionLogEntry.ActionType.ROUND_START_TIME_SET round_redirect_pattern_name = 'draw-display' def post(self, request, *args, **kwargs): diff --git a/tabbycat/importer/archive.py b/tabbycat/importer/archive.py index 080fc0ebc3b..579928ecbd4 100644 --- a/tabbycat/importer/archive.py +++ b/tabbycat/importer/archive.py @@ -576,7 +576,7 @@ def import_debates(self): # Debate-teams for j, side in enumerate(debate.findall('side'), side_start): - position = list(DebateTeam.Side)[j][0] + position = list(DebateTeam.Side)[j] debateteam_obj = DebateTeam(debate=debate_obj, team=self.teams[side.get('team')], side=position) debateteam_obj.save() self.debateteams[(debate.get('id'), side.get('team'))] = debateteam_obj diff --git a/tabbycat/importer/views.py b/tabbycat/importer/views.py index a28877228bd..ae429b9b10f 100644 --- a/tabbycat/importer/views.py +++ b/tabbycat/importer/views.py @@ -103,7 +103,7 @@ class ImportInstitutionsWizardView(BaseImportWizardView): ('raw', ImportInstitutionsRawForm), ('details', modelformset_factory(Institution, fields=('name', 'code'), extra=0)), ] - action_log_type = ActionLogEntry.ACTION_TYPE_SIMPLE_IMPORT_INSTITUTIONS + action_log_type = ActionLogEntry.ActionType.SIMPLE_IMPORT_INSTITUTIONS def get_details_form_initial(self): return self.get_cleaned_data_for_step('raw')['institutions_raw'] @@ -119,7 +119,7 @@ class ImportVenuesWizardView(BaseImportWizardView): ('raw', ImportVenuesRawForm), ('details', modelformset_factory(Venue, form=VenueDetailsForm, extra=0)), ] - action_log_type = ActionLogEntry.ACTION_TYPE_SIMPLE_IMPORT_VENUES + action_log_type = ActionLogEntry.ActionType.SIMPLE_IMPORT_VENUES def get_form_kwargs(self, step): if step == 'details': @@ -179,7 +179,7 @@ class ImportTeamsWizardView(BaseImportByInstitutionWizardView): ('numbers', ImportTeamsNumbersForm), ('details', modelformset_factory(Team, form=TeamDetailsForm, formset=TeamDetailsFormSet, extra=0)), ] - action_log_type = ActionLogEntry.ACTION_TYPE_SIMPLE_IMPORT_TEAMS + action_log_type = ActionLogEntry.ActionType.SIMPLE_IMPORT_TEAMS def get_details_instance_initial(self, i): return {'reference': str(i), 'use_institution_prefix': True} @@ -202,7 +202,7 @@ class ImportAdjudicatorsWizardView(BaseImportByInstitutionWizardView): ('numbers', ImportAdjudicatorsNumbersForm), ('details', modelformset_factory(Adjudicator, form=AdjudicatorDetailsForm, extra=0)), ] - action_log_type = ActionLogEntry.ACTION_TYPE_SIMPLE_IMPORT_ADJUDICATORS + action_log_type = ActionLogEntry.ActionType.SIMPLE_IMPORT_ADJUDICATORS def get_default_base_score(self): """Returns the midpoint of the configured allowable score range.""" diff --git a/tabbycat/motions/models.py b/tabbycat/motions/models.py index 94b0600d7dd..f72be12ad5c 100644 --- a/tabbycat/motions/models.py +++ b/tabbycat/motions/models.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from html2text import html2text class Motion(models.Model): @@ -27,6 +28,17 @@ class Meta: def __str__(self): return self.text + def clean_fields(self, exclude=None): + super().clean_fields(exclude=exclude) + if html2text(self.info_slide or '').isspace() and 'info_slide' not in exclude: + self.info_slide = '' + + @property + def info_slide_plain(self): + if (self.info_slide or '').startswith('{% trans "Info Slide" %} @@ -93,6 +100,25 @@ {{ block.super }} {# this is the Vue table, which is populated with previous results #} + + + {% endblock %} @@ -106,4 +132,14 @@ }); }); + {% if checkins_used %} + + + {% endif %} {% endblock js %} diff --git a/tabbycat/privateurls/views.py b/tabbycat/privateurls/views.py index 725969d19e9..6d7469c7d88 100644 --- a/tabbycat/privateurls/views.py +++ b/tabbycat/privateurls/views.py @@ -213,6 +213,7 @@ def get_context_data(self, **kwargs) -> Dict[str, Any]: try: checkin_id = PersonIdentifier.objects.get(person=self.object) kwargs['checkins_used'] = True + kwargs['identifier'] = checkin_id checkins = get_unexpired_checkins(t, 'checkin_window_people') diff --git a/tabbycat/results/admin.py b/tabbycat/results/admin.py index bb96d76406e..e95bc0f69fe 100644 --- a/tabbycat/results/admin.py +++ b/tabbycat/results/admin.py @@ -119,8 +119,7 @@ def get_queryset(self, request): @admin.register(SpeakerScoreByAdj) class SpeakerScoreByAdjAdmin(TabbycatModelAdminFieldsMixin, ModelAdmin): - list_display = ('id', 'ballot_submission', 'get_round', 'get_adj_name', 'get_team', - 'get_speaker_name', 'position', 'score') + list_display = ('id', 'ballot_submission', 'get_round', 'get_adj_name', 'get_team', 'position', 'get_speaker_name', 'score') search_fields = ('debate_team__debate__round__seq', 'debate_team__team__short_name', 'debate_team__team__institution__name', 'debate_adjudicator__adjudicator__name') @@ -129,6 +128,10 @@ class SpeakerScoreByAdjAdmin(TabbycatModelAdminFieldsMixin, ModelAdmin): 'debate_adjudicator__type') raw_id_fields = ('debate_team', 'debate_adjudicator', 'ballot_submission') + @admin.display(description=_("Speaker")) + def get_speaker_name(self, obj): + return obj.speaker_name + def get_queryset(self, request): speaker_person = SpeakerScore.objects.filter( ballot_submission_id=OuterRef('ballot_submission_id'), @@ -140,6 +143,7 @@ def get_queryset(self, request): 'ballot_submission__debate__round__tournament', 'debate_adjudicator__adjudicator', 'debate_team__team__tournament', + 'debate_team__debate__round__tournament', ).prefetch_related( Prefetch('ballot_submission__debate__debateteam_set', queryset=DebateTeam.objects.select_related('team')), diff --git a/tabbycat/results/forms.py b/tabbycat/results/forms.py index 4032a58ff18..6dd34821af4 100644 --- a/tabbycat/results/forms.py +++ b/tabbycat/results/forms.py @@ -48,12 +48,12 @@ def clean(self, value): return value -class BaseScoreField(forms.FloatField): +class BaseScoreField(forms.DecimalField): def __init__(self, *args, **kwargs): """Takes an additional optional keyword argument: tournament, the Tournament used to configure the field.""" - tournament = kwargs.pop('tournament') + tournament = kwargs.pop('tournament', None) if tournament: min_value = tournament.pref(self.CONFIG_MIN_VALUE_FIELD) max_value = tournament.pref(self.CONFIG_MAX_VALUE_FIELD) @@ -64,37 +64,12 @@ def __init__(self, *args, **kwargs): step_value = self.DEFAULT_STEP_VALUE self.step_value = kwargs.get('step_value', step_value) - kwargs.setdefault('min_value', self.coerce_for_ui(min_value)) - kwargs.setdefault('max_value', self.coerce_for_ui(max_value)) + kwargs.setdefault('min_value', min_value) + kwargs.setdefault('max_value', max_value) + kwargs.setdefault('step_size', self.step_value) super().__init__(*args, **kwargs) - def validate(self, value): - super().validate(value) - self.check_value(value) - - def check_value(self, value): - if value and self.step_value and value % self.step_value != 0: - if self.step_value == 1: - msg = _("Please enter a whole number.") - else: - msg = _("Please enter a multiple of %s.") % self.step_value - raise forms.ValidationError(msg, code='decimal') - - def widget_attrs(self, widget): - attrs = super().widget_attrs(widget) - if isinstance(widget, forms.NumberInput): - attrs['step'] = self.coerce_for_ui(self.step_value) # override - return attrs - - def coerce_for_ui(self, x): - if x is None: - return None - if self.step_value % 1 == 0: - return int(x) - else: - return float(x) - class MotionModelChoiceField(forms.ModelChoiceField): to_field_name = 'motion_id' @@ -133,7 +108,7 @@ class BaseResultForm(forms.Form): result_class = None - def __init__(self, ballotsub, password=False, *args, **kwargs): + def __init__(self, ballotsub, tabroom, password=False, *args, **kwargs): self.ballotsub = ballotsub self.result = kwargs.pop('result', self.result_class(self.ballotsub)) self.filled = kwargs.pop('filled', False) @@ -143,6 +118,8 @@ def __init__(self, ballotsub, password=False, *args, **kwargs): self.tournament = self.debate.round.tournament self.has_tournament_password = password and self.tournament.pref('public_use_password') + self.tabroom = tabroom + self.use_codes = use_team_code_names_data_entry(self.tournament, tabroom) status_choices = Debate.STATUS_CHOICES if self.tournament.pref('enable_postponements') else Debate.STATUS_CHOICES_RESTRICTED self.fields['debate_result_status'] = forms.ChoiceField(choices=status_choices) @@ -308,10 +285,11 @@ def create_fields(self): assert len(teams) == 2 side_choices = [ (None, _("---------")), - (str(teams[0].id) + "," + str(teams[1].id), - _("%(aff_team)s affirmed, %(neg_team)s negated") % {'aff_team': teams[0].short_name, 'neg_team': teams[1].short_name}), - (str(teams[1].id) + "," + str(teams[0].id), - _("%(aff_team)s affirmed, %(neg_team)s negated") % {'aff_team': teams[1].short_name, 'neg_team': teams[0].short_name}), + *[(str(teams[i].id) + "," + str(teams[(i+1) % 2].id), + _("%(aff_team)s affirmed, %(neg_team)s negated") % { + 'aff_team': team_name_for_data_entry(teams[i], self.use_codes), + 'neg_team': team_name_for_data_entry(teams[(i+1) % 2], self.use_codes), + }) for i in range(2)], ] self.fields['choose_sides'] = forms.TypedChoiceField( choices=side_choices, @@ -338,7 +316,7 @@ def create_fields(self): def create_declared_winner_dropdown(self): """This method creates a drop-down with a list of the teams in the debate""" teams = [(s, _("%(team)s (%(side)s)") % { - 'team': self.debate.get_team(s).short_name, 'side': get_side_name(self.tournament, s, 'full')}) for s in self.sides] + 'team': team_name_for_data_entry(self.debate.get_team(s), self.use_codes), 'side': get_side_name(self.tournament, s, 'full')}) for s in self.sides] return forms.TypedChoiceField( label=_("Winner"), required=True, empty_value=None, choices=[(None, _("---------"))] + teams, @@ -535,8 +513,6 @@ def clean(self): def clean_speakers(self, cleaned_data): """Checks that the speaker selections are valid.""" - use_codes = use_team_code_names_data_entry(self.tournament, True) - # Pull team info again, in case it's changed since the form was loaded. if self.choosing_sides: teams = cleaned_data.get('choose_sides', [None] * len(self.sides)) @@ -558,7 +534,7 @@ def clean_speakers(self, cleaned_data): if team is not None and speaker not in team.speakers: self.add_error(self._fieldname_speaker(side, pos), forms.ValidationError( _("The speaker %(speaker)s doesn't appear to be on team %(team)s."), - params={'speaker': speaker.get_public_name(self.tournament), 'team': team_name_for_data_entry(team, use_codes)}, + params={'speaker': speaker.get_public_name(self.tournament), 'team': team_name_for_data_entry(team, self.use_codes)}, code='speaker_wrongteam'), ) @@ -702,8 +678,7 @@ def initial_from_result(self, result): for side, pos in product(self.sides, self.positions): score = result.get_score(side, pos) - coerce_for_ui = self.fields[self._fieldname_score(side, pos)].coerce_for_ui - initial[self._fieldname_score(side, pos)] = coerce_for_ui(score) + initial[self._fieldname_score(side, pos)] = score if self.using_speaker_ranks: initial[self._fieldname_srank(side, pos)] = result.get_speaker_rank(side, pos) @@ -849,8 +824,7 @@ def initial_from_result(self, result): for adj in self.adjudicators: for side, pos in product(self.sides, self.positions): score = result.get_score(adj, side, pos) - coerce_for_ui = self.fields[self._fieldname_score(adj, side, pos)].coerce_for_ui - initial[self._fieldname_score(adj, side, pos)] = coerce_for_ui(score) + initial[self._fieldname_score(adj, side, pos)] = score if self.using_declared_winner: initial[self._fieldname_declared_winner(adj)] = result.get_winner(adj) @@ -946,7 +920,7 @@ def create_participant_fields(self): def create_team_selector(self): # 3(a). List of teams in multiple-select side_choices = [(side, _("%(team)s (%(side)s)") % { - 'team': self.debate.get_team(side).short_name, + 'team': team_name_for_data_entry(self.debate.get_team(side), self.use_codes), 'side': self._side_name(side)}) for side in self.tournament.sides] return forms.MultipleChoiceField(choices=side_choices, widget=forms.CheckboxSelectMultiple) diff --git a/tabbycat/results/result.py b/tabbycat/results/result.py index 8fc1a4df7e8..c30277f9544 100644 --- a/tabbycat/results/result.py +++ b/tabbycat/results/result.py @@ -460,18 +460,16 @@ def populate_from_merge(self, *results) -> list[ResultError]: for error in errors: key, side, pos = error.args[1:] - # Clear ghosts for speaker order problems too - self.set_ghost(side, pos, False) + if key == 'ghost': + self.set_ghost(side, pos, False) if key == 'speaker': self.set_speaker(side, pos, None) - for adj in self.debateadjs: - self.set_score(adj, side, pos, None) return errors - def merge_speaker_result(self, result, adj): - pass + def merge_speaker_result(self, result, adj) -> list[ResultError]: + return [] def save(self): super().save() @@ -703,7 +701,6 @@ def merge_speaker_order(self, result: BaseDebateResult) -> list[ResultError]: self.set_speaker(side, pos, result.get_speaker(side, pos)) elif result.get_speaker(side, pos) != cur_speaker: errors.append(ResultError("Inconsistent speaker order", "speaker", side, pos)) - continue # Don't care about setting ghost/score if can't attribute to the correct speaker if not self.get_ghost(side, pos) and result.get_ghost(side, pos): self.set_ghost(side, pos, result.get_ghost(side, pos)) @@ -922,17 +919,22 @@ def populate_from_merge(self, *results) -> list[ResultError]: self.set_winners(set()) # Clear ghosts for speaker order problems too - if key in ('ghost', 'speaker', 'scores'): + if key == 'ghost': self.set_ghost(side, pos, False) - if key in ('speaker', 'scores'): + if key == 'speaker': self.set_speaker(side, pos, None) + + if key == 'scores': self.set_score(side, pos, None) + if key == 'speaker_ranks': + self.set_speaker_rank(side, pos, None) + return errors - def merge_speaker_result(self, result): - pass + def merge_speaker_result(self, result) -> list[ResultError]: + return [] # -------------------------------------------------------------------------- # Team score fields @@ -987,6 +989,11 @@ def merge_speaker_result(self, result: BaseDebateResult) -> list[ResultError]: self.set_score(side, pos, result.get_score(side, pos)) elif self.get_score(side, pos) != result.get_score(side, pos): errors.append(ResultError('Scores are not identical', 'scores', side, pos)) + + if self.get_speaker_rank(side, pos) is None: + self.set_speaker_rank(side, pos, result.get_speaker_rank(side, pos)) + elif self.get_speaker_rank(side, pos) != result.get_speaker_rank(side, pos): + errors.append(ResultError('Speech ranks are not identical', 'speaker_ranks', side, pos)) return errors def get_speaker_rank(self, side: str, position: int) -> int: diff --git a/tabbycat/results/utils.py b/tabbycat/results/utils.py index c375cf8c696..50928360fe3 100644 --- a/tabbycat/results/utils.py +++ b/tabbycat/results/utils.py @@ -77,15 +77,6 @@ def format_dt(dt, t, use_codes): return result_winner, result -def set_float_or_int(number, step_value): - """Used to ensure the values sent through to the frontend are - either Ints or Floats such that the validation can handle them properly""" - if step_value.is_integer(): - return int(number) - else: - return number - - def get_result_status_stats(round): """Returns a dict where keys are result statuses of debates; values are the number of debates in the round with that status. diff --git a/tabbycat/results/views.py b/tabbycat/results/views.py index dc2ae648320..822a0769fd2 100644 --- a/tabbycat/results/views.py +++ b/tabbycat/results/views.py @@ -1,5 +1,4 @@ import logging -from itertools import groupby from asgiref.sync import async_to_sync from channels.layers import get_channel_layer @@ -315,6 +314,7 @@ def get_form_class(self): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['ballotsub'] = self.ballotsub + kwargs['tabroom'] = self.tabroom return kwargs def add_success_message(self): @@ -419,7 +419,7 @@ class BaseNewBallotSetView(SingleObjectFromTournamentMixin, BaseBallotSetView): model = Debate tournament_field_name = 'round__tournament' relates_to_new_ballotsub = True - action_log_type = ActionLogEntry.ACTION_TYPE_BALLOT_CREATE + action_log_type = ActionLogEntry.ActionType.BALLOT_CREATE pk_url_kwarg = 'debate_id' page_title = gettext_lazy("New Ballot Set") @@ -479,10 +479,10 @@ class BaseEditBallotSetView(SingleObjectFromTournamentMixin, BaseBallotSetView): def get_action_log_type(self): if self.ballotsub.discarded: - return ActionLogEntry.ACTION_TYPE_BALLOT_DISCARD + return ActionLogEntry.ActionType.BALLOT_DISCARD elif self.ballotsub.confirmed: - return ActionLogEntry.ACTION_TYPE_BALLOT_CONFIRM - return ActionLogEntry.ACTION_TYPE_BALLOT_EDIT + return ActionLogEntry.ActionType.BALLOT_CONFIRM + return ActionLogEntry.ActionType.BALLOT_EDIT def get_success_url(self): return reverse_round('results-round-list', self.ballotsub.debate.round) @@ -531,7 +531,7 @@ class BasePublicNewBallotSetView(PersonalizablePublicTournamentPageMixin, RoundM template_name = 'public_enter_results.html' relates_to_new_ballotsub = True - action_log_type = ActionLogEntry.ACTION_TYPE_BALLOT_SUBMIT + action_log_type = ActionLogEntry.ActionType.BALLOT_SUBMIT page_title = gettext_lazy("Enter Results") def get_context_data(self, **kwargs): @@ -875,6 +875,24 @@ def get_form_kwargs(self): kwargs['filled'] = True return kwargs + def get_form(self): + form = super().get_form() + for error in self.errors: + msg, t, side, pos, values = error.args + if t == 'speaker': + field = form._fieldname_speaker(side, pos) + elif t == 'ghost': + field = form._fieldname_ghost(side, pos) + elif t == 'winners': + field = form._fieldname_declared_winner() + elif t == 'scores': + field = form._fieldname_score(side, pos) + elif t == 'speaker_ranks': + field = form._fieldname_srank(side, pos) + form.cleaned_data = {} + form.add_error(field, ValidationError(msg)) + return form + def populate_objects(self, prefill=True): super().populate_objects() self.round = self.debate.round @@ -887,15 +905,7 @@ def populate_objects(self, prefill=True): # Handle result conflicts self.result = DebateResult(self.ballotsub, tournament=self.tournament) - errors = self.result.populate_from_merge(*[b.result for b in bses]) - for t, errors in groupby(errors, key=lambda e: e.args[1]): - if t == 'speaker': - msg = _("The speaking order in the ballots is inconsistent. Affected speakers are blanked. Make sure the speaker order and scores are correct.") - elif t == 'ghost': - msg = _("Duplicate speeches are marked inconsistently, so could not be consolidated. Make sure speeches are marked according to the tournament's rules.") - elif t == 'scores': - msg = _("Some scores were not identical, and so are left blank. Make sure the speaker order and scores are correct.") - messages.error(self.request, msg) + self.errors = self.result.populate_from_merge(*[b.result for b in bses]) # Handle motion conflicts bs_motions = BallotSubmission.objects.filter( diff --git a/tabbycat/settings/__init__.py b/tabbycat/settings/__init__.py index 96f39b1d2a8..89ae49b0501 100644 --- a/tabbycat/settings/__init__.py +++ b/tabbycat/settings/__init__.py @@ -26,7 +26,7 @@ root.info('SPLIT_SETTINGS: imported github.py') elif os.environ.get('IN_DOCKER', '') and bool(int(os.environ['IN_DOCKER'])): base_settings.append('docker.py') - root.info('SPLIT_SETTINGS: imported github.py') + root.info('SPLIT_SETTINGS: imported docker.py') elif os.environ.get('ON_HEROKU', ''): base_settings.append('heroku.py') root.info('SPLIT_SETTINGS: imported heroku.py') diff --git a/tabbycat/settings/core.py b/tabbycat/settings/core.py index 25f6dba6663..a7ccbec4926 100644 --- a/tabbycat/settings/core.py +++ b/tabbycat/settings/core.py @@ -22,9 +22,9 @@ # Version # ============================================================================== -TABBYCAT_VERSION = '2.8.0-dev' -TABBYCAT_CODENAME = 'Quokka' -READTHEDOCS_VERSION = 'v2.8.0' +TABBYCAT_VERSION = '2.9.0-dev' +TABBYCAT_CODENAME = 'Ragdoll' +READTHEDOCS_VERSION = 'v2.9.0' # ============================================================================== # Internationalization and Localization diff --git a/tabbycat/standings/teams.py b/tabbycat/standings/teams.py index 448086c2a0b..019b65287c5 100644 --- a/tabbycat/standings/teams.py +++ b/tabbycat/standings/teams.py @@ -189,7 +189,7 @@ def annotate(self, queryset, standings, round=None): opponents_annotation = ArrayAgg('debateteam__debate__debateteam__team_id', filter=opponents_filter) logger.info("Opponents annotation: %s", str(opponents_annotation)) - teams_with_opponents = queryset.all().annotate(opponent_ids=opponents_annotation) + teams_with_opponents = queryset.model.objects.annotate(opponent_ids=opponents_annotation) opponents_by_team = {team.id: team.opponent_ids for team in teams_with_opponents} opp_metric_queryset = self.opponent_annotator().get_annotated_queryset( diff --git a/tabbycat/standings/tests/test_standings.py b/tabbycat/standings/tests/test_standings.py index 4634e1535f1..3ea5a2dd473 100644 --- a/tabbycat/standings/tests/test_standings.py +++ b/tabbycat/standings/tests/test_standings.py @@ -1,5 +1,4 @@ import logging -from unittest import expectedFailure from django.test import TestCase @@ -253,13 +252,15 @@ def setUp(self): debate = self.set_up_ignorable_debate() debate.ballotsubmission_set.update(confirmed=False) - @expectedFailure def test_draw_strength(self): - super().test_draw_strength() + # The ignorable debate still counts as an opponent (because they definitely faced each + # other) but does not count as a win (because it's unconfirmed). So the losing team has + # faced the winning team 3 times (including ignorable debate), and the winning team has 2 + # wins (excluding ignorable debate), for a losing team draw strength of 3 * 2 = 6. + self._base_metric_test({'draw_strength': [0, 6]}) - @expectedFailure def test_draw_strength_speaks(self): - super().test_draw_strength_speaks() + self._base_metric_test({'draw_strength_speaks': [591, 609]}) class TestBasicStandings(TestCase): diff --git a/tabbycat/standings/views.py b/tabbycat/standings/views.py index 01033b34aa4..f3d68b1dfcf 100644 --- a/tabbycat/standings/views.py +++ b/tabbycat/standings/views.py @@ -281,7 +281,7 @@ def populate_result_missing(self, standings): def cast_round_results(self, standings, rounds, step_preference): """For use by subclasses. Casts round results to integers if appropriate according to tournament preferences.""" - if self.tournament.pref(step_preference).is_integer(): + if self.tournament.pref(step_preference) % 1 == 0: is_consensus_by_round = [self.tournament.ballots_per_debate(r.stage) == 'per-debate' for r in rounds] for info in standings: for i, is_consensus in enumerate(is_consensus_by_round): diff --git a/tabbycat/tournaments/models.py b/tabbycat/tournaments/models.py index 4fe96becab5..272b25736c6 100644 --- a/tabbycat/tournaments/models.py +++ b/tabbycat/tournaments/models.py @@ -125,10 +125,10 @@ def integer_scores(self, stage): the value in question is in fact an integer before casting.""" if self.ballots_per_debate(stage) == 'per-adj': return False - if not self.pref('score_step').is_integer(): + if not self.pref('score_step').as_integer_ratio()[1] == 1: return False if (self.pref('reply_scores_enabled') and - not self.pref('reply_score_step').is_integer()): + not self.pref('reply_score_step').as_integer_ratio()[1] == 1): return False return True diff --git a/tabbycat/tournaments/views.py b/tabbycat/tournaments/views.py index 2e3f74ab18f..95aaf73fe6e 100644 --- a/tabbycat/tournaments/views.py +++ b/tabbycat/tournaments/views.py @@ -167,7 +167,7 @@ def post(self, request, *args, **kwargs): class CompleteRoundView(RoundMixin, AdministratorMixin, LogActionMixin, PostOnlyRedirectView): - action_log_type = ActionLogEntry.ACTION_TYPE_ROUND_COMPLETE + action_log_type = ActionLogEntry.ActionType.ROUND_COMPLETE def post(self, request, *args, **kwargs): self.round.completed = True diff --git a/tabbycat/users/groups.py b/tabbycat/users/groups.py index 16b6bdf98a5..f5becde90ba 100644 --- a/tabbycat/users/groups.py +++ b/tabbycat/users/groups.py @@ -50,8 +50,8 @@ class AdjudicationCore(BaseGroup): Permission.EDIT_MOTION, Permission.EDIT_STARTTIME, Permission.EDIT_PREFORMEDPANELS, - Permission.EDIT_RELEASEMOTION, - Permission.EDIT_UNRELEASEMOTION, + Permission.RELEASE_MOTION, + Permission.UNRELEASE_MOTION, Permission.EDIT_ROOMALLOCATIONS, Permission.EDIT_ALLOCATESIDES, Permission.EDIT_ADJ_BREAK, diff --git a/tabbycat/users/views.py b/tabbycat/users/views.py index 09fecad2da7..ffaf13d1a1b 100644 --- a/tabbycat/users/views.py +++ b/tabbycat/users/views.py @@ -64,7 +64,7 @@ class InviteUserView(LogActionMixin, AdministratorMixin, TournamentMixin, Passwo form_class = InviteUserForm template_name = "invite_user.html" - action_log_type = ActionLogEntry.ACTION_TYPE_USER_INVITE + action_log_type = ActionLogEntry.ActionType.USER_INVITE page_title = _("Invite User") page_emoji = '👤' diff --git a/tabbycat/utils/tables.py b/tabbycat/utils/tables.py index 2dfd6e81710..438f88382d2 100644 --- a/tabbycat/utils/tables.py +++ b/tabbycat/utils/tables.py @@ -857,12 +857,15 @@ def add_debate_ballot_link_column(self, debates, show_ballot=False): self.add_column(ballot_links_header, ballot_links_data) elif self.private_url: - debates = Debate.objects.filter(pk__in=[d.pk for d in debates]).select_related('round').annotate( + debateqs = Debate.objects.filter(pk__in=[d.pk for d in debates]).select_related('round').annotate( has_ballot=Exists(BallotSubmission.objects.filter(debate_id=OuterRef('id')).exclude(discarded=True)), ).prefetch_related( - Prefetch('ballotsubmission_set', queryset=BallotSubmission.objects.exclude(discarded=True), to_attr='nondiscard_ballots')) + Prefetch('ballotsubmission_set', queryset=BallotSubmission.objects.exclude(discarded=True), to_attr='nondiscard_ballots'), + ).all() + annotated_debates = {d.pk: d for d in debateqs} ballot_links_data = [] - for debate in debates: + for o_debate in debates: + debate = annotated_debates[o_debate.pk] if not debate.has_ballot: ballot_links_data.append(no_ballot) elif not get_result_class(debate.nondiscard_ballots[0], debate.round, self.tournament).uses_speakers: diff --git a/tabbycat/venues/consumers.py b/tabbycat/venues/consumers.py index 850bd6b342d..f2278d4a847 100644 --- a/tabbycat/venues/consumers.py +++ b/tabbycat/venues/consumers.py @@ -22,7 +22,7 @@ def allocate_debate_venues(self, event): return allocate_venues(round) - self.log_action(event['extra'], round, ActionLogEntry.ACTION_TYPE_VENUES_AUTOALLOCATE) + self.log_action(event['extra'], round, ActionLogEntry.ActionType.VENUES_AUTOALLOCATE) content = self.reserialize_debates(SimpleDebateVenueSerializer, round) msg = _("Successfully auto-allocated rooms to debates.") diff --git a/tabbycat/venues/views.py b/tabbycat/venues/views.py index d2ecc2e2acd..f2ee223dab5 100644 --- a/tabbycat/venues/views.py +++ b/tabbycat/venues/views.py @@ -56,7 +56,7 @@ class VenueCategoriesView(LogActionMixin, AdministratorMixin, TournamentMixin, M edit_permission = Permission.EDIT_ROOMCATEGORIES template_name = 'venue_categories_edit.html' formset_model = VenueCategory - action_log_type = ActionLogEntry.ACTION_TYPE_VENUE_CATEGORIES_EDIT + action_log_type = ActionLogEntry.ActionType.VENUE_CATEGORIES_EDIT def get_formset_factory_kwargs(self): queryset = self.tournament.relevant_venues.prefetch_related('venuecategory_set') @@ -105,7 +105,7 @@ class VenueConstraintsView(AdministratorMixin, LogActionMixin, TournamentMixin, edit_permission = Permission.EDIT_ROOMCONSTRAINTS template_name = 'venue_constraints_edit.html' formset_model = VenueConstraint - action_log_type = ActionLogEntry.ACTION_TYPE_VENUE_CONSTRAINTS_EDIT + action_log_type = ActionLogEntry.ActionType.VENUE_CONSTRAINTS_EDIT def get_formset_factory_kwargs(self): # Need to build a dynamic choices list for the widget; so override the