diff --git a/apps/base/dev/tasks.py b/apps/base/dev/tasks.py index c737a45dd..9cfd47fd9 100644 --- a/apps/base/dev/tasks.py +++ b/apps/base/dev/tasks.py @@ -9,6 +9,8 @@ from models.volunteer.shift import Shift from models.volunteer.role import Role +from apps.cfp.tasks import create_tags + from . import dev_cli from .fake import FakeDataGenerator @@ -20,6 +22,7 @@ def dev_data(ctx): ctx.invoke(fake_data) ctx.invoke(volunteer_data) ctx.invoke(volunteer_shifts) + ctx.invoke(create_tags) @dev_cli.command("cfp_data") diff --git a/apps/cfp/tasks.py b/apps/cfp/tasks.py index 66904a696..42f5b0fb6 100644 --- a/apps/cfp/tasks.py +++ b/apps/cfp/tasks.py @@ -6,6 +6,7 @@ from main import db from models.cfp import Proposal, TalkProposal, WorkshopProposal, InstallationProposal +from models.cfp_tag import Tag, DEFAULT_TAGS from models.user import User from apps.cfp_review.base import send_email_for_proposal from ..common.email import from_email @@ -125,3 +126,27 @@ def email_reserve(): send_email_for_proposal( proposal, reason="reserve-list", from_address=from_email("SPEAKERS_EMAIL") ) + + +@cfp.cli.command( + "create_tags", + help=f"Add tags to the database. Defaults are {DEFAULT_TAGS}.", +) +@click.argument("tags_to_create", nargs=-1) +def create_tags(tags_to_create): + """Upset tag list""" + if not tags_to_create: + tags_to_create = DEFAULT_TAGS + + tags_created = 0 + for tag in tags_to_create: + if Tag.query.filter_by(tag=tag).all(): + app.logger.info(f"'{tag}' already exists, skipping.") + continue + + db.session.add(Tag(tag)) + tags_created += 1 + app.logger.info(f"'{tag}' added to session.") + + db.session.commit() + app.logger.info(f"Successfully created {tags_created} new tags.") diff --git a/apps/cfp_review/base.py b/apps/cfp_review/base.py index decfcd8b4..f5b41af6a 100644 --- a/apps/cfp_review/base.py +++ b/apps/cfp_review/base.py @@ -22,20 +22,23 @@ 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 from models.purchase import Ticket from .forms import ( @@ -138,6 +141,22 @@ def filter_proposal_request(): ) ) + tags = request.args.getlist("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)) + ) + sort_dict = get_proposal_sort_dict(request.args) proposal_query = proposal_query.options(joinedload(Proposal.user)).options( joinedload("user.owned_tickets") @@ -159,12 +178,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, ) @@ -295,6 +320,8 @@ def log_and_close(msg, next_page, proposal_id=None): else: raise Exception("Unknown cfp type {}".format(prop.type)) + form.tags.choices = [(t.tag, t.tag) for t in Tag.query.order_by(Tag.tag).all()] + # Process the POST if form.validate_on_submit(): try: @@ -343,6 +370,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 = [t.tag for t in prop.tags] form.requirements.data = prop.requirements form.length.data = prop.length form.notice_required.data = prop.notice_required @@ -1173,4 +1201,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/apps/cfp_review/forms.py b/apps/cfp_review/forms.py index 3f38607fc..3122b3ef4 100644 --- a/apps/cfp_review/forms.py +++ b/apps/cfp_review/forms.py @@ -4,16 +4,17 @@ StringField, FieldList, FormField, - RadioField, SelectField, TextAreaField, BooleanField, IntegerField, FloatField, + SelectMultipleField, ) 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 = SelectMultipleField("Tags") requirements = TextAreaField("Requirements") length = StringField("Length") notice_required = SelectField( @@ -50,7 +52,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"), @@ -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/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..1c970efc0 --- /dev/null +++ b/models/cfp_tag.py @@ -0,0 +1,64 @@ +from main import db +import sqlalchemy +from . import BaseModel + + +DEFAULT_TAGS = [ + "computing", + "film", + "health", + "misc", + "music", + "radio", + "robotics", + "science", + "security", + "trains", + "show & tell", +] + + +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(",")] + tag_list = [t.strip().lower() for t in tag_str] + 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), +) 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")}}