From 2bac9b3ded3fab194daaa3e964ef3f1bac7558b1 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 20 Oct 2023 17:35:53 +0100 Subject: [PATCH 1/7] Add cfp_tag model --- .../versions/0042c470500c_add_cfp_tags.py | 63 +++++++++++++++++++ models/cfp.py | 8 +++ models/cfp_tag.py | 48 ++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 migrations/versions/0042c470500c_add_cfp_tags.py create mode 100644 models/cfp_tag.py diff --git a/migrations/versions/0042c470500c_add_cfp_tags.py b/migrations/versions/0042c470500c_add_cfp_tags.py new file mode 100644 index 000000000..7987f9a64 --- /dev/null +++ b/migrations/versions/0042c470500c_add_cfp_tags.py @@ -0,0 +1,63 @@ +"""add cfp tags + +Revision ID: 0042c470500c +Revises: 279d06c3289e +Create Date: 2023-12-22 10:08:06.559664 + +""" + +# revision identifiers, used by Alembic. +revision = '0042c470500c' +down_revision = '279d06c3289e' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_tag_version', + sa.Column('proposal_id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('tag_id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('proposal_id', 'tag_id', 'transaction_id', name=op.f('pk_proposal_tag_version')) + ) + op.create_index(op.f('ix_proposal_tag_version_operation_type'), 'proposal_tag_version', ['operation_type'], unique=False) + op.create_index(op.f('ix_proposal_tag_version_transaction_id'), 'proposal_tag_version', ['transaction_id'], unique=False) + op.create_table('tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tag', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tag')), + sa.UniqueConstraint('tag', name=op.f('uq_tag_tag')) + ) + op.create_table('tag_version', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('tag', sa.String(), autoincrement=False, nullable=False), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('id', 'transaction_id', name=op.f('pk_tag_version')) + ) + op.create_index(op.f('ix_tag_version_operation_type'), 'tag_version', ['operation_type'], unique=False) + op.create_index(op.f('ix_tag_version_transaction_id'), 'tag_version', ['transaction_id'], unique=False) + op.create_table('proposal_tag', + sa.Column('proposal_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name=op.f('fk_proposal_tag_proposal_id_proposal')), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_proposal_tag_tag_id_tag')), + sa.PrimaryKeyConstraint('proposal_id', 'tag_id', name=op.f('pk_proposal_tag')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('proposal_tag') + op.drop_index(op.f('ix_tag_version_transaction_id'), table_name='tag_version') + op.drop_index(op.f('ix_tag_version_operation_type'), table_name='tag_version') + op.drop_table('tag_version') + op.drop_table('tag') + op.drop_index(op.f('ix_proposal_tag_version_transaction_id'), table_name='proposal_tag_version') + op.drop_index(op.f('ix_proposal_tag_version_operation_type'), table_name='proposal_tag_version') + op.drop_table('proposal_tag_version') + # ### end Alembic commands ### diff --git a/models/cfp.py b/models/cfp.py index 65299087a..76f845763 100644 --- a/models/cfp.py +++ b/models/cfp.py @@ -21,6 +21,7 @@ from main import db from .user import User +from .cfp_tag import ProposalTag from . import BaseModel @@ -370,6 +371,13 @@ class Proposal(BaseModel): notice_required = db.Column(db.String) private_notes = db.Column(db.String) + tags = db.relationship( + "Tag", + backref="proposals", + cascade="all", + secondary=ProposalTag, + ) + # Flags needs_help = db.Column(db.Boolean, nullable=False, default=False) needs_money = db.Column(db.Boolean, nullable=False, default=False) diff --git a/models/cfp_tag.py b/models/cfp_tag.py new file mode 100644 index 000000000..7296013ba --- /dev/null +++ b/models/cfp_tag.py @@ -0,0 +1,48 @@ +from main import db +import sqlalchemy +from . import BaseModel + + +class Tag(BaseModel): + __versioned__: dict = {} + __tablename__ = "tag" + + id = db.Column(db.Integer, primary_key=True) + tag = db.Column(db.String, nullable=False, unique=True) + + def __init__(self, tag: str): + self.tag = tag.strip().lower() + + def __str__(self): + return self.tag + + def __repr__(self): + return f"" + + @classmethod + def serialise_tags(self, tag_list: list["Tag"]) -> str: + return ",".join([str(t) for t in tag_list]) + + @classmethod + def parse_serialised_tags(cls, tag_str: str) -> list["Tag"]: + res = [] + tag_list = [t.strip().lower() for t in tag_str.split(",")] + for tag_value in tag_list: + if len(tag_value) == 0: + continue + tag = cls.query.filter_by(tag=tag_value).one_or_none() + if tag: + res.append(tag) + else: + res.append(Tag(tag_value)) + return res + + +ProposalTag: sqlalchemy.Table = db.Table( + "proposal_tag", + BaseModel.metadata, + db.Column( + "proposal_id", db.Integer, db.ForeignKey("proposal.id"), primary_key=True + ), + db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True), +) From b452736ccd45df5951304600459817e4c6d40e8e Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 20 Oct 2023 18:46:20 +0100 Subject: [PATCH 2/7] Add tags to CfP form --- apps/cfp_review/base.py | 2 ++ apps/cfp_review/forms.py | 3 +++ templates/cfp_review/update_proposal.html | 1 + 3 files changed, 6 insertions(+) diff --git a/apps/cfp_review/base.py b/apps/cfp_review/base.py index decfcd8b4..c006e9478 100644 --- a/apps/cfp_review/base.py +++ b/apps/cfp_review/base.py @@ -36,6 +36,7 @@ EVENT_SPACING, FavouriteProposal, ) +from models.cfp_tag import Tag from models.user import User from models.purchase import Ticket from .forms import ( @@ -343,6 +344,7 @@ def log_and_close(msg, next_page, proposal_id=None): form.state.data = prop.state form.title.data = prop.title form.description.data = prop.description + form.tags.data = Tag.serialise_tags(prop.tags) form.requirements.data = prop.requirements form.length.data = prop.length form.notice_required.data = prop.notice_required diff --git a/apps/cfp_review/forms.py b/apps/cfp_review/forms.py index 3f38607fc..8c29c0bf0 100644 --- a/apps/cfp_review/forms.py +++ b/apps/cfp_review/forms.py @@ -14,6 +14,7 @@ from wtforms.validators import DataRequired, Optional, NumberRange, ValidationError from models.cfp import Venue, ORDERED_STATES +from models.cfp_tag import Tag from ..common.forms import Form, HiddenIntegerField, EmailField from dateutil.parser import parse as parse_date @@ -24,6 +25,7 @@ class UpdateProposalForm(Form): state = SelectField("State", choices=[(s, s) for s in ORDERED_STATES]) title = StringField("Title", [DataRequired()]) description = TextAreaField("Description", [DataRequired()]) + tags = StringField("Tags") requirements = TextAreaField("Requirements") length = StringField("Length") notice_required = SelectField( @@ -94,6 +96,7 @@ def validate_allowed_times(self, field): def update_proposal(self, proposal): proposal.title = self.title.data proposal.description = self.description.data + proposal.tags = Tag.parse_serialised_tags(self.tags.data) proposal.requirements = self.requirements.data proposal.length = self.length.data proposal.notice_required = self.notice_required.data diff --git a/templates/cfp_review/update_proposal.html b/templates/cfp_review/update_proposal.html index d645d231f..3b0b147eb 100644 --- a/templates/cfp_review/update_proposal.html +++ b/templates/cfp_review/update_proposal.html @@ -34,6 +34,7 @@

{{proposal.published_title or proposal.title}}
{{ render_dl_field(form.state) }} {{ render_dl_field(form.title) }} {{ render_dl_field(form.description, rows=8) }} + {{ render_dl_field(form.tags) }} {{ render_dl_field(form.requirements, rows=3) }} {{ render_dl_field(form.length) }} {{ render_dl_field(form.notice_required) }} From 423450297d39c67f1c02f6f7d9767ba24c5d0a47 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 24 Dec 2023 16:48:24 +1300 Subject: [PATCH 3/7] Add tags as a filter option --- apps/cfp_review/base.py | 13 +++++++++++++ templates/cfp_review/_proposals_filter_form.html | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/cfp_review/base.py b/apps/cfp_review/base.py index c006e9478..e08fe24aa 100644 --- a/apps/cfp_review/base.py +++ b/apps/cfp_review/base.py @@ -139,6 +139,13 @@ def filter_proposal_request(): ) ) + tags = request.args.getlist("tags") + if tags: + filtered = True + proposal_query = proposal_query.join(Proposal.tags).filter( + Proposal.tags.any(Tag.tag.in_(tags)) + ) + sort_dict = get_proposal_sort_dict(request.args) proposal_query = proposal_query.options(joinedload(Proposal.user)).options( joinedload("user.owned_tickets") @@ -160,12 +167,18 @@ def proposals(): if "reverse" in non_sort_query_string: del non_sort_query_string["reverse"] + tag_counts = {t.tag: [0, len(t.proposals)] for t in Tag.query.all()} + for prop in proposals: + for t in prop.tags: + tag_counts[t.tag][0] = tag_counts[t.tag][0] + 1 + return render_template( "cfp_review/proposals.html", proposals=proposals, new_qs=non_sort_query_string, filtered=filtered, total_proposals=Proposal.query.count(), + tag_counts=tag_counts, ) diff --git a/templates/cfp_review/_proposals_filter_form.html b/templates/cfp_review/_proposals_filter_form.html index 7fbaf56ef..02e733cde 100644 --- a/templates/cfp_review/_proposals_filter_form.html +++ b/templates/cfp_review/_proposals_filter_form.html @@ -29,6 +29,20 @@ +
+ +
+ +
+
From d28e3b503c7004bc115622fd0a943853b3e8f84f Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 24 Dec 2023 17:52:33 +1300 Subject: [PATCH 4/7] Proposal form view improvements --- apps/cfp_review/forms.py | 3 +- templates/cfp_review/update_proposal.html | 226 ++++++++++++++-------- 2 files changed, 147 insertions(+), 82 deletions(-) diff --git a/apps/cfp_review/forms.py b/apps/cfp_review/forms.py index 8c29c0bf0..bb09dbc8a 100644 --- a/apps/cfp_review/forms.py +++ b/apps/cfp_review/forms.py @@ -4,7 +4,6 @@ StringField, FieldList, FormField, - RadioField, SelectField, TextAreaField, BooleanField, @@ -52,7 +51,7 @@ class UpdateProposalForm(Form): telephone_number = StringField("Telephone") eventphone_number = StringField("On-site extension") may_record = BooleanField("May record") - needs_laptop = RadioField( + needs_laptop = SelectField( "Needs laptop", choices=[ (0, "Is providing their own laptop"), diff --git a/templates/cfp_review/update_proposal.html b/templates/cfp_review/update_proposal.html index 3b0b147eb..707569ba7 100644 --- a/templates/cfp_review/update_proposal.html +++ b/templates/cfp_review/update_proposal.html @@ -20,87 +20,153 @@

{{proposal.published_title or proposal.title}}
{{ form.hidden_tag() }}
-
-
Submitted
-
{{proposal.created.strftime("%Y-%m-%d %H:%M")}}
-
E-Mail
-
{{proposal.user.email}}
- {% if proposal.anonymiser_id %} -
Anoymised by
-
{{ proposal.anonymiser.name }}
+
+
+ +
+
+
+
Submitted
+
{{proposal.created.strftime("%Y-%m-%d %H:%M")}}
+
E-Mail
+
{{proposal.user.email}}
+ {% if proposal.anonymiser_id %} +
Anoymised by
+
{{ proposal.anonymiser.name }}
+ {% endif %} +
Num favourites
+
{{ proposal.favourite_count }}
+ {{ render_dl_field(form.state) }} + {{ render_dl_field(form.title) }} + {{ render_dl_field(form.description, rows=8) }} + {{ render_dl_field(form.tags) }} +
+
+
+
+
+ +
+
+
+ {{ render_dl_field(form.requirements, rows=3) }} + {{ render_dl_field(form.length) }} + {{ render_dl_field(form.notice_required) }} + {{ render_dl_field(form.needs_help) }} + {{ render_dl_field(form.needs_money) }} + {{ render_dl_field(form.needs_laptop) }} + {{ render_dl_field(form.one_day) }} + {% if proposal.type == 'workshop' or proposal.type == 'youthworkshop' %} + {{ render_dl_field(form.attendees) }} + {{ render_dl_field(form.cost) }} + {{ render_dl_field(form.participant_equipment) }} + {{ render_dl_field(form.age_range) }} + {% if proposal.type == 'youthworkshop' %} + {{ render_dl_field(form.valid_dbs) }} + {% endif %} + {% elif proposal.type == 'installation' %} + {{ render_dl_field(form.size) }} + {{ render_dl_field(form.funds) }} + {% endif %} + {{ render_dl_field(form.will_have_ticket) }} +
+
+
+
+
+ +
+
+
+ {% if proposal.type == "lightning" %} + {{ render_dl_field(form.session) }} + {{ render_dl_field(form.slide_link) }} + {% else %} + {{ render_dl_field(form.user_scheduled) }} + {{ render_dl_field(form.hide_from_schedule) }} + {{ render_dl_field(form.allowed_venues) }} + {{ render_dl_field(form.allowed_times) }} + {{ render_dl_field(form.scheduled_duration) }} + {{ render_dl_field(form.scheduled_venue) }} + {{ render_dl_field(form.scheduled_time) }} + {{ render_dl_field(form.potential_venue) }} + {{ render_dl_field(form.potential_time) }} + {% endif %} +
+
+
+
+ {% if proposal.state in ('accepted', 'finished') %} +
+ +
+
+
+ {{ render_dl_field(form.published_names) }} + {{ render_dl_field(form.published_pronouns) }} + {{ render_dl_field(form.published_title) }} + {{ render_dl_field(form.published_description, rows=4) }} + {{ render_dl_field(form.content_note) }} + {{ render_dl_field(form.family_friendly, rows=4) }} + {% if proposal.type == 'workshop' or proposal.type == 'youthworkshop' %} + {{ render_dl_field(form.published_age_range) }} + {{ render_dl_field(form.published_cost) }} + {{ render_dl_field(form.published_participant_equipment) }} + {% endif %} +
+
+
+
+
+ +
+
+
+ {{ render_dl_field(form.telephone_number) }} + {{ render_dl_field(form.eventphone_number) }} + {% if proposal.type == 'talk' %} + {{ render_dl_field(form.may_record) }} + {{ render_radio_field(form.needs_laptop) }} + {% endif %} + {{ render_dl_field(form.arrival_period) }} + {{ render_dl_field(form.departure_period) }} + {{ render_dl_field(form.available_times) }} +
+
+
+
{% endif %} -
Num favourites
-
{{ proposal.favourite_count }}
- {{ render_dl_field(form.state) }} - {{ render_dl_field(form.title) }} - {{ render_dl_field(form.description, rows=8) }} - {{ render_dl_field(form.tags) }} - {{ render_dl_field(form.requirements, rows=3) }} - {{ render_dl_field(form.length) }} - {{ render_dl_field(form.notice_required) }} - {{ render_dl_field(form.needs_help) }} - {{ render_dl_field(form.needs_money) }} - {{ render_dl_field(form.needs_laptop) }} - {{ render_dl_field(form.one_day) }} - {% if proposal.type == 'workshop' or proposal.type == 'youthworkshop' %} - {{ render_dl_field(form.attendees) }} - {{ render_dl_field(form.cost) }} - {{ render_dl_field(form.participant_equipment) }} - {{ render_dl_field(form.age_range) }} - {% if proposal.type == 'youthworkshop' %} - {{ render_dl_field(form.valid_dbs) }} - {% endif %} - {% elif proposal.type == 'installation' %} - {{ render_dl_field(form.size) }} - {{ render_dl_field(form.funds) }} - {% endif %} - {{ render_dl_field(form.will_have_ticket) }} -
-

Scheduling

-
- {% if proposal.type == "lightning" %} - {{ render_dl_field(form.session) }} - {{ render_dl_field(form.slide_link) }} - {% else %} - {{ render_dl_field(form.user_scheduled) }} - {{ render_dl_field(form.hide_from_schedule) }} - {{ render_dl_field(form.allowed_venues) }} - {{ render_dl_field(form.allowed_times) }} - {{ render_dl_field(form.scheduled_duration) }} - {{ render_dl_field(form.scheduled_venue) }} - {{ render_dl_field(form.scheduled_time) }} - {{ render_dl_field(form.potential_venue) }} - {{ render_dl_field(form.potential_time) }} - {% endif %} -
- {% if proposal.state in ('accepted', 'finished') %} -

User schedule published details

-
- {{ render_dl_field(form.published_names) }} - {{ render_dl_field(form.published_pronouns) }} - {{ render_dl_field(form.published_title) }} - {{ render_dl_field(form.published_description, rows=4) }} - {{ render_dl_field(form.content_note) }} - {{ render_dl_field(form.family_friendly, rows=4) }} - {% if proposal.type == 'workshop' or proposal.type == 'youthworkshop' %} - {{ render_dl_field(form.published_age_range) }} - {{ render_dl_field(form.published_cost) }} - {{ render_dl_field(form.published_participant_equipment) }} - {% endif %} -
-

User scheduling information

-
- {{ render_dl_field(form.telephone_number) }} - {{ render_dl_field(form.eventphone_number) }} - {% if proposal.type == 'talk' %} - {{ render_dl_field(form.may_record) }} - {{ render_radio_field(form.needs_laptop) }} - {% endif %} - {{ render_dl_field(form.arrival_period) }} - {{ render_dl_field(form.departure_period) }} - {{ render_dl_field(form.available_times) }} -
- {% endif %} +
{{ form.update(class_="btn btn-primary debounce", tabindex=1) }}

From b8bafdec3c3feba0f5e48a5b16adc7fac421eee7 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 24 Dec 2023 20:10:47 +1300 Subject: [PATCH 5/7] Add a CfP summary page Mostly for tracking which tags have what --- apps/cfp_review/base.py | 53 +++++++++++++++---- templates/cfp_review/_nav.html | 2 + .../cfp_review/_proposals_filter_form.html | 8 +++ templates/cfp_review/proposals_summary.html | 27 ++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 templates/cfp_review/proposals_summary.html diff --git a/apps/cfp_review/base.py b/apps/cfp_review/base.py index e08fe24aa..860b0cc3d 100644 --- a/apps/cfp_review/base.py +++ b/apps/cfp_review/base.py @@ -22,19 +22,21 @@ from main import db, external_url from .majority_judgement import calculate_max_normalised_score from models.cfp import ( - Proposal, - LightningTalkProposal, CFPMessage, CFPVote, - Venue, - InvalidVenueException, - MANUAL_REVIEW_TYPES, - get_available_proposal_minutes, - ROUGH_LENGTHS, - get_days_map, DEFAULT_VENUES, EVENT_SPACING, FavouriteProposal, + get_available_proposal_minutes, + get_days_map, + HUMAN_CFP_TYPES, + InvalidVenueException, + LightningTalkProposal, + MANUAL_REVIEW_TYPES, + ORDERED_STATES, + Proposal, + ROUGH_LENGTHS, + Venue, ) from models.cfp_tag import Tag from models.user import User @@ -140,7 +142,16 @@ def filter_proposal_request(): ) tags = request.args.getlist("tags") - if tags: + if "untagged" in tags: + if len(tags) > 1: + flash("'untagged' in 'tags' arg, other tags ignored") + filtered = True + # join(..outer=True) == left outer join + proposal_query = proposal_query.join(Proposal.tags, isouter=True).filter( + Tag.id.is_(None) + ) + + elif tags: filtered = True proposal_query = proposal_query.join(Proposal.tags).filter( Proposal.tags.any(Tag.tag.in_(tags)) @@ -1188,4 +1199,28 @@ def lightning_talks(): ) +@cfp_review.route("/proposals-summary") +@schedule_required +def proposals_summary(): + counts_by_tag = {t.tag: len(t.proposals) for t in Tag.query.all()} + counts_by_tag["untagged"] = 0 + + counts_by_type = {t: 0 for t in HUMAN_CFP_TYPES} + counts_by_state = {s: 0 for s in ORDERED_STATES} + + for prop in Proposal.query.all(): + counts_by_type[prop.type] += 1 + counts_by_state[prop.state] += 1 + + if not prop.tags: + counts_by_tag["untagged"] += 1 + + return render_template( + "cfp_review/proposals_summary.html", + counts_by_tag=counts_by_tag, + counts_by_type=counts_by_type, + counts_by_state=counts_by_state, + ) + + from . import venues # noqa diff --git a/templates/cfp_review/_nav.html b/templates/cfp_review/_nav.html index a93c7d832..14d7724a7 100644 --- a/templates/cfp_review/_nav.html +++ b/templates/cfp_review/_nav.html @@ -30,6 +30,8 @@ {{ menuitem('Anon-blocked', ".proposals", proposal_counts['anon-blocked'], state='anon-blocked')}} {{ menuitem('New', ".proposals", proposal_counts['new'], state='new')}} + + {{ menuitem('Summary', ".proposals_summary")}}