From 6147ffaa5836df10e9f745419708fa553ffe76b0 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Tue, 30 Jan 2024 19:06:00 +0000 Subject: [PATCH] Move Venues list into the database. Venues now have a list of proposal types for which they: * can be selected (allowed_types) * are in the default allowed set for (default_for_types) Venues also have a capacity, and proposals are now associated with allowed venues using a foreign key rather than a comma-separated string. There's some slightly hairy logic inside the migrations to properly populate capacity/default_for_types/allowed_types/allowed_venues the first time; some of these migrations are lossy in the reverse direction (so don't do that). Realistically speaking there needs to be some better logic here, because actually different venues can host different _types_ at different times, but we don't yet represent venue-slots in any meaningful sense in the data model. Fixes #1210. --- apps/cfp/schedule_tasks.py | 177 ++++++++++++++---- apps/cfp/scheduler.py | 9 +- apps/cfp_review/__init__.py | 4 +- apps/cfp_review/base.py | 23 ++- apps/cfp_review/forms.py | 13 +- apps/cfp_review/venues.py | 21 ++- ...e_create_default_for_types_and_allowed_.py | 72 +++++++ .../c594b354e0d3_create_capacity_on_venue.py | 46 +++++ ...9_convert_allowed_venues_to_foreign_key.py | 85 +++++++++ models/cfp.py | 90 ++++----- templates/cfp_review/venues/edit.html | 3 + templates/cfp_review/venues/index.html | 3 + 12 files changed, 437 insertions(+), 109 deletions(-) create mode 100644 migrations/versions/8dd432ba3ede_create_default_for_types_and_allowed_.py create mode 100644 migrations/versions/c594b354e0d3_create_capacity_on_venue.py create mode 100644 migrations/versions/ce8ca8ebe029_convert_allowed_venues_to_foreign_key.py diff --git a/apps/cfp/schedule_tasks.py b/apps/cfp/schedule_tasks.py index 4c298487b..7b490b1c6 100644 --- a/apps/cfp/schedule_tasks.py +++ b/apps/cfp/schedule_tasks.py @@ -1,6 +1,7 @@ """ CLI commands for scheduling """ import click +from dataclasses import dataclass from flask import current_app as app from sqlalchemy import func @@ -13,60 +14,156 @@ from ..common.email import from_email -# This should probably be moved to models/cfp.py and DEFAULT_VENUES merged in -EMF_VENUES = { - "Stage A": (100, (52.03961, -2.37787), True, "talk"), - "Stage B": (99, (52.04190, -2.37664), True, "talk,performance"), - "Stage C": (98, (52.04050, -2.37765), True, "talk"), - "Workshop 1": (97, (52.04259, -2.37515), True, "workshop"), - "Workshop 2": (96, (52.04208, -2.37715), True, "workshop"), - "Workshop 3": (95, (52.04129, -2.37578), True, "workshop"), - "Workshop 4": (94, (52.04329, -2.37590), True, "workshop"), - "Workshop 5": (93, (52.040938, -2.37706), True, "workshop"), - "Youth Workshop": (92, (52.04117, -2.37771), True, "youthworkshop"), - "Main Bar": (91, (52.04180, -2.37727), False, "talk,performance"), - "Lounge": ( - 90, - (52.04147, -2.37644), - False, - "talk,performance,workshop,youthworkshop", +@dataclass +class VenueDefinition: + name: str + priority: int + latlon: tuple[float, float] + scheduled_content_only: bool + allowed_types: list[str] + default_for_types: list[str] + capacity: int | None + + @property + def location(self) -> str: + if self.latlon: + return f"POINT({self.latlon[1]} {self.latlon[0]})" + else: + return None + + def as_venue(self) -> Venue: + return Venue( + name=self.name, + priority=self.priority, + location=self.location, + scheduled_content_only=self.scheduled_content_only, + allowed_types=self.allowed_types, + default_for_types=self.default_for_types, + capacity=self.capacity, + ) + + +# This lives only here, on purpose, because this is just intended to seed the DB. +_EMF_VENUES = [ + VenueDefinition( + name="Stage A", + priority=100, + latlon=(52.03961, -2.37787), + scheduled_content_only=True, + allowed_types=["talk"], + default_for_types=["talk"], + capacity=1000, + ), + VenueDefinition( + name="Stage B", + priority=99, + latlon=(52.04190, -2.37664), + scheduled_content_only=True, + allowed_types=["talk", "performance"], + default_for_types=["talk", "performance", "lightning"], + capacity=600, + ), + VenueDefinition( + name="Stage C", + priority=98, + latlon=(52.04050, -2.37765), + scheduled_content_only=True, + allowed_types=["talk"], + default_for_types=["talk", "lightning"], + capacity=450, + ), + VenueDefinition( + name="Workshop 1", + priority=97, + latlon=(52.04259, -2.37515), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, + ), + VenueDefinition( + name="Workshop 2", + priority=96, + latlon=(52.04208, -2.37715), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, + ), + VenueDefinition( + name="Workshop 3", + priority=95, + latlon=(52.04129, -2.37578), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, + ), + VenueDefinition( + name="Workshop 4", + priority=94, + latlon=(52.04329, -2.37590), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, ), -} + VenueDefinition( + name="Workshop 5", + priority=93, + latlon=(52.040938, -2.37706), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, + ), + VenueDefinition( + name="Youth Workshop", + priority=92, + latlon=(52.04117, -2.37771), + scheduled_content_only=True, + allowed_types=["workshop"], + default_for_types=["workshop"], + capacity=30, + ), + VenueDefinition( + name="Main Bar", + priority=91, + latlon=(52.04180, -2.37727), + scheduled_content_only=False, + allowed_types=["talk", "performance"], + default_for_types=[], + capacity=None, + ), + VenueDefinition( + name="Lounge", + priority=90, + latlon=(52.04147, -2.37644), + scheduled_content_only=False, + allowed_types=["talk", "performance", "workshop", "youthworkshop"], + default_for_types=[], + capacity=None, + ), +] @cfp.cli.command("create_venues") def create_venues(): """Create venues defined in code""" - for name, ( - priority, - latlon, - scheduled_content_only, - type_str, - ) in EMF_VENUES.items(): + for venue_definition in _EMF_VENUES: + name = venue_definition.name venue = Venue.query.filter_by(name=name).all() - if latlon: - location = f"POINT({latlon[1]} {latlon[0]})" - else: - location = None - if len(venue) == 1 and venue[0].location is None: - venue[0].location = location + venue[0].location = venue_definition.location app.logger.info(f"Updating venue {name} with new latlon") continue elif venue: app.logger.info(f"Venue {name} already exists") continue - venue = Venue( - name=name, - type=type_str, - priority=priority, - location=location, - scheduled_content_only=scheduled_content_only, - ) - db.session.add(venue) - app.logger.info(f"Adding venue {name} with type {type_str}") + db.session.add(venue_definition.as_venue()) + app.logger.info(f"Adding venue {name}") db.session.commit() @@ -76,7 +173,7 @@ def create_village_venues(): for village in Village.query.all(): venue = Venue.query.filter_by(village_id=village.id).first() if venue: - if venue.name in EMF_VENUES: + if venue.name in _EMF_VENUES: app.logger.info(f"Not updating EMF venue {venue.name}") elif venue.name != village.name: diff --git a/apps/cfp/scheduler.py b/apps/cfp/scheduler.py index a427d5a32..8747c2bf9 100644 --- a/apps/cfp/scheduler.py +++ b/apps/cfp/scheduler.py @@ -10,8 +10,6 @@ Venue, ROUGH_LENGTHS, EVENT_SPACING, - DEFAULT_VENUES, - VENUE_CAPACITY, ) @@ -60,10 +58,9 @@ def get_scheduler_data( proposals_by_type[proposal.type].append(proposal) capacity_by_type = defaultdict(dict) - for type, venues in DEFAULT_VENUES.items(): - for venue in venues: - venue_id = Venue.query.filter(Venue.name == venue).one().id - capacity_by_type[type][venue_id] = VENUE_CAPACITY[venue] + for venue in Venue.query.all(): # TODO(lukegb): filter to emf venues + for type in venue.default_for_types: + capacity_by_type[type][venue.id] = venue.capacity proposal_data = [] for type, proposals in proposals_by_type.items(): diff --git a/apps/cfp_review/__init__.py b/apps/cfp_review/__init__.py index 66e7e2ecf..e7001de97 100644 --- a/apps/cfp_review/__init__.py +++ b/apps/cfp_review/__init__.py @@ -5,10 +5,10 @@ Proposal, CFPMessage, CFPVote, + Venue, CFP_STATES, ORDERED_STATES, HUMAN_CFP_TYPES, - DEFAULT_VENUES, ) from ..common import require_permission @@ -99,7 +99,7 @@ def cfp_review_variables(): "proposal_counts": proposal_counts, "unread_reviewer_notes": unread_reviewer_notes, "view_name": request.url_rule.endpoint.replace("cfp_review.", "."), - "emf_venues": sum(DEFAULT_VENUES.values(), []), + "emf_venues": [v.name for v in Venue.emf_venues()], } diff --git a/apps/cfp_review/base.py b/apps/cfp_review/base.py index 59b2f20a0..7e5175ca2 100644 --- a/apps/cfp_review/base.py +++ b/apps/cfp_review/base.py @@ -24,7 +24,6 @@ from models.cfp import ( CFPMessage, CFPVote, - DEFAULT_VENUES, EVENT_SPACING, FavouriteProposal, get_available_proposal_minutes, @@ -321,6 +320,17 @@ def log_and_close(msg, next_page, proposal_id=None): 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()] + form.allowed_venues.choices = [ + (v.id, v.name) + for v in Venue.query.filter( + db.or_( + Venue.allowed_types.any(prop.type), + Venue.id.in_(v.id for v in prop.get_allowed_venues()), + ) + ) + .order_by(Venue.priority.desc()) + .all() + ] # Process the POST if form.validate_on_submit(): @@ -399,7 +409,7 @@ def log_and_close(msg, next_page, proposal_id=None): form.user_scheduled.data = prop.user_scheduled form.hide_from_schedule.data = prop.hide_from_schedule - form.allowed_venues.data = prop.get_allowed_venues_serialised() + form.allowed_venues.data = [v.id for v in prop.get_allowed_venues()] form.allowed_times.data = prop.get_allowed_time_periods_serialised() form.scheduled_time.data = prop.scheduled_time form.scheduled_duration.data = prop.scheduled_duration @@ -1004,8 +1014,11 @@ def rank(): # Correct for changeover period not being needed at the end of the day num_days = len(get_days_map().items()) + for type, amount in allocated_minutes.items(): - num_venues = len(DEFAULT_VENUES[type]) + num_venues = Venue.query.filter( + Venue.default_for_types.any(type) + ).count() # TODO(lukegb): filter to emf venues # Amount of minutes per venue * number of venues - (slot changeover period) from the end allocated_minutes[type] = amount - ( (10 * EVENT_SPACING[type]) * num_days * num_venues @@ -1123,11 +1136,13 @@ def scheduler(): schedule_data.append(export) + venue_names_by_type = Venue.emf_venue_names_by_type() + return render_template( "cfp_review/scheduler.html", shown_venues=shown_venues, schedule_data=schedule_data, - default_venues=DEFAULT_VENUES, + default_venues=venue_names_by_type, ) diff --git a/apps/cfp_review/forms.py b/apps/cfp_review/forms.py index ed1f79015..d315cb605 100644 --- a/apps/cfp_review/forms.py +++ b/apps/cfp_review/forms.py @@ -69,7 +69,7 @@ class UpdateProposalForm(Form): family_friendly = BooleanField("Family Friendly") hide_from_schedule = BooleanField("Hide from schedule") - allowed_venues = StringField("Allowed Venues") + allowed_venues = SelectMultipleField("Allowed Venues", coerce=int) allowed_times = TextAreaField("Allowed Time Periods") scheduled_duration = StringField("Duration") scheduled_time = StringField("Scheduled Time") @@ -197,14 +197,9 @@ def update_proposal(self, proposal): else: proposal.potential_venue = None - # Only set this if we're overriding the default - if ( - proposal.get_allowed_venues_serialised().strip() - != self.allowed_venues.data.strip() - ): - proposal.allowed_venues = self.allowed_venues.data.strip() - # Validates the new data. Bit nasty. - proposal.get_allowed_venues() + proposal.allowed_venues = Venue.query.filter( + Venue.id.in_(self.allowed_venues.data) + ).all() class ConvertProposalForm(Form): diff --git a/apps/cfp_review/venues.py b/apps/cfp_review/venues.py index 183238683..df9bb106f 100644 --- a/apps/cfp_review/venues.py +++ b/apps/cfp_review/venues.py @@ -5,12 +5,19 @@ flash, ) -from wtforms import StringField, SelectField, BooleanField, SubmitField -from wtforms.validators import DataRequired +from wtforms import ( + StringField, + SelectField, + BooleanField, + SubmitField, + SelectMultipleField, + IntegerField, +) +from wtforms.validators import DataRequired, Optional from geoalchemy2.shape import to_shape from main import db -from models.cfp import Venue, Proposal +from models.cfp import Venue, Proposal, HUMAN_CFP_TYPES from models.village import Village from . import ( cfp_review, @@ -19,11 +26,19 @@ from ..common.forms import Form +VENUE_TYPE_CHOICES = [(k, v) for k, v in HUMAN_CFP_TYPES.items()] + + class VenueForm(Form): name = StringField("Name", [DataRequired()]) village_id = SelectField("Village", choices=[], coerce=int) scheduled_content_only = BooleanField("Scheduled Content Only") latlon = StringField("Location") + allowed_types = SelectMultipleField("Allowed for", choices=VENUE_TYPE_CHOICES) + default_for_types = SelectMultipleField( + "Default Venue for", choices=VENUE_TYPE_CHOICES + ) + capacity = IntegerField("Capacity", validators=[Optional()]) submit = SubmitField("Save") delete = SubmitField("Delete") diff --git a/migrations/versions/8dd432ba3ede_create_default_for_types_and_allowed_.py b/migrations/versions/8dd432ba3ede_create_default_for_types_and_allowed_.py new file mode 100644 index 000000000..b64b3c228 --- /dev/null +++ b/migrations/versions/8dd432ba3ede_create_default_for_types_and_allowed_.py @@ -0,0 +1,72 @@ +"""Create default_for_types and allowed_types on Venue + +Revision ID: 8dd432ba3ede +Revises: ce8ca8ebe029 +Create Date: 2024-01-30 02:40:15.284397 + +""" + +# revision identifiers, used by Alembic. +revision = '8dd432ba3ede' +down_revision = 'ce8ca8ebe029' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('tag_version', 'tag', + existing_type=sa.VARCHAR(), + nullable=True, + autoincrement=False) + op.add_column('venue', sa.Column('allowed_types', postgresql.ARRAY(sa.String()), nullable=True)) + op.add_column('venue', sa.Column('default_for_types', postgresql.ARRAY(sa.String()), nullable=True)) + + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + meta.reflect(only=('venue',)) + venue_tbl = sa.Table('venue', meta) + + op.execute( + sa.update(venue_tbl) + .values(allowed_types=sa.func.string_to_array(venue_tbl.c.type, ',')) + ) + for venue_name, default_for_types in [ + ('Stage A', ['talk']), + ('Stage B', ['talk', 'performance', 'lightning']), + ('Stage C', ['talk', 'lightning']), + ('Workshop 1', ['workshop']), + ('Workshop 2', ['workshop']), + ('Workshop 3', ['workshop']), + ('Workshop 4', ['workshop']), + ('Workshop 5', ['workshop']), + ('Youth Workshop', ['youthworkshop']), + ]: + op.execute(sa.update(venue_tbl).where(venue_tbl.c.name == venue_name).values(default_for_types=default_for_types)) + + op.drop_column('venue', 'type') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venue', sa.Column('type', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.drop_column('venue', 'default_for_types') + op.drop_column('venue', 'allowed_types') + + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + meta.reflect(only=('venue',)) + venue_tbl = sa.Table('venue', meta) + + op.execute( + sa.update(venue_tbl) + .values(type=sa.func.array_to_string(venue_tbl.c.allowed_types, ',')) + ) + + op.alter_column('tag_version', 'tag', + existing_type=sa.VARCHAR(), + nullable=False, + autoincrement=False) + # ### end Alembic commands ### diff --git a/migrations/versions/c594b354e0d3_create_capacity_on_venue.py b/migrations/versions/c594b354e0d3_create_capacity_on_venue.py new file mode 100644 index 000000000..dc9a82e93 --- /dev/null +++ b/migrations/versions/c594b354e0d3_create_capacity_on_venue.py @@ -0,0 +1,46 @@ +"""Create capacity on Venue + +Revision ID: c594b354e0d3 +Revises: 8dd432ba3ede +Create Date: 2024-01-30 02:46:27.381445 + +""" + +# revision identifiers, used by Alembic. +revision = 'c594b354e0d3' +down_revision = '8dd432ba3ede' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venue', sa.Column('capacity', sa.Integer(), nullable=True)) + + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + meta.reflect(only=('venue',)) + venue_tbl = sa.Table('venue', meta) + + for venue_name, capacity in [ + ('Stage A', 1000), + ('Stage B', 600), + ('Stage C', 450), + ('Workshop 1', 30), + ('Workshop 2', 30), + ('Workshop 3', 30), + ('Workshop 4', 30), + ('Workshop 5', 30), + ('Youth Workshop', 30), + ]: + op.execute(sa.update(venue_tbl).where(venue_tbl.c.name == venue_name).values(capacity=capacity)) + + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('venue', 'capacity') + # ### end Alembic commands ### diff --git a/migrations/versions/ce8ca8ebe029_convert_allowed_venues_to_foreign_key.py b/migrations/versions/ce8ca8ebe029_convert_allowed_venues_to_foreign_key.py new file mode 100644 index 000000000..30ede9af7 --- /dev/null +++ b/migrations/versions/ce8ca8ebe029_convert_allowed_venues_to_foreign_key.py @@ -0,0 +1,85 @@ +"""Convert allowed venues to foreign key + +Revision ID: ce8ca8ebe029 +Revises: 5e48dc411113 +Create Date: 2024-01-30 01:34:25.399340 + +""" + +# revision identifiers, used by Alembic. +revision = 'ce8ca8ebe029' +down_revision = '5e48dc411113' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_allowed_venues_version', + sa.Column('proposal_id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('venue_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', 'venue_id', 'transaction_id', name=op.f('pk_proposal_allowed_venues_version')) + ) + op.create_index(op.f('ix_proposal_allowed_venues_version_operation_type'), 'proposal_allowed_venues_version', ['operation_type'], unique=False) + op.create_index(op.f('ix_proposal_allowed_venues_version_transaction_id'), 'proposal_allowed_venues_version', ['transaction_id'], unique=False) + allowed_venues_tbl = op.create_table('proposal_allowed_venues', + sa.Column('proposal_id', sa.Integer(), nullable=False), + sa.Column('venue_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name=op.f('fk_proposal_allowed_venues_proposal_id_proposal')), + sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], name=op.f('fk_proposal_allowed_venues_venue_id_venue')), + sa.PrimaryKeyConstraint('proposal_id', 'venue_id', name=op.f('pk_proposal_allowed_venues')) + ) + + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + meta.reflect(only=('proposal', 'venue')) + proposal_tbl = sa.Table('proposal', meta) + venue_tbl = sa.Table('venue', meta) + venue_names = sa.func.unnest(sa.func.string_to_array(proposal_tbl.c.allowed_venues, ",")).table_valued() + proposal_to_venue_select = ( + sa.select(proposal_tbl.c.id, venue_tbl.c.id) + .join_from(venue_names, venue_tbl, venue_tbl.c.name == venue_names.column) + ) + op.execute( + allowed_venues_tbl + .insert().from_select(['proposal_id', 'venue_id'], proposal_to_venue_select) + ) + + op.drop_column('proposal', 'allowed_venues') + op.drop_column('proposal_version', 'allowed_venues') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('tag_version', 'tag', + existing_type=sa.VARCHAR(), + nullable=False, + autoincrement=False) + op.add_column('proposal_version', sa.Column('allowed_venues', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('proposal', sa.Column('allowed_venues', sa.VARCHAR(), autoincrement=False, nullable=True)) + + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + meta.reflect(only=('proposal', 'venue', 'proposal_allowed_venues')) + proposal_tbl = sa.Table('proposal', meta) + venue_tbl = sa.Table('venue', meta) + proposal_allowed_venues_tbl = sa.Table('proposal_allowed_venues', meta) + op.execute( + sa.update(proposal_tbl) + .values(allowed_venues=sa.func.array_to_string(sa.func.array( + sa.select(venue_tbl.c.name) + .where(proposal_allowed_venues_tbl.c.venue_id == venue_tbl.c.id) + .where(proposal_allowed_venues_tbl.c.proposal_id == proposal_tbl.c.id) + .scalar_subquery() + ), ',')) + ) + + op.drop_table('proposal_allowed_venues') + op.drop_index(op.f('ix_proposal_allowed_venues_version_transaction_id'), table_name='proposal_allowed_venues_version') + op.drop_index(op.f('ix_proposal_allowed_venues_version_operation_type'), table_name='proposal_allowed_venues_version') + op.drop_table('proposal_allowed_venues_version') + # ### end Alembic commands ### diff --git a/models/cfp.py b/models/cfp.py index 8126d716d..b2a19bb26 100644 --- a/models/cfp.py +++ b/models/cfp.py @@ -6,6 +6,8 @@ from itertools import groupby from geoalchemy2 import Geometry from geoalchemy2.shape import to_shape +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.ext.mutable import MutableList from sqlalchemy import UniqueConstraint, func, select from sqlalchemy.orm import column_property @@ -220,29 +222,6 @@ cfp_period = namedtuple("cfp_period", "start end") -# We may also have other venues in the DB, but these are the ones to be -# returned by default if there are none -DEFAULT_VENUES: dict[str, list[str]] = { - "talk": ["Stage A", "Stage B", "Stage C"], - "workshop": ["Workshop 1", "Workshop 2", "Workshop 3", "Workshop 4", "Workshop 5"], - "youthworkshop": ["Youth Workshop"], - "performance": ["Stage B"], - "installation": [], - "lightning": ["Stage B", "Stage C"], -} - -VENUE_CAPACITY = { - "Stage A": 1000, - "Stage B": 600, - "Stage C": 450, - "Workshop 1": 30, - "Workshop 2": 30, - "Workshop 3": 30, - "Workshop 4": 30, - "Workshop 5": 30, - "Youth Workshop": 30, -} - # List of submission types which are manually reviewed rather than through # the anonymous review system. MANUAL_REVIEW_TYPES = ["youthworkshop", "performance", "installation"] @@ -320,6 +299,7 @@ def make_periods_contiguous(time_periods): def get_available_proposal_minutes(): minutes = defaultdict(int) + venue_names_by_type = Venue.emf_venue_names_by_type() for type, slots in PROPOSAL_TIMESLOTS.items(): periods = make_periods_contiguous( [timeslot_to_period(ts, type=type) for ts in slots] @@ -327,7 +307,7 @@ def get_available_proposal_minutes(): for period in periods: minutes[type] += int( (period.end - period.start).total_seconds() / 60 - ) * len(DEFAULT_VENUES[type]) + ) * len(venue_names_by_type[type]) return minutes @@ -349,6 +329,16 @@ class InvalidVenueException(Exception): ) +ProposalAllowedVenues = db.Table( + "proposal_allowed_venues", + BaseModel.metadata, + db.Column( + "proposal_id", db.Integer, db.ForeignKey("proposal.id"), primary_key=True + ), + db.Column("venue_id", db.Integer, db.ForeignKey("venue.id"), primary_key=True), +) + + class Proposal(BaseModel): __versioned__ = {"exclude": ["favourites", "favourite_count"]} __tablename__ = "proposal" @@ -420,7 +410,11 @@ class Proposal(BaseModel): # Fields for scheduling hide_from_schedule = db.Column(db.Boolean, default=False, nullable=False) - allowed_venues = db.Column(db.String, nullable=True) + allowed_venues = db.relationship( + "Venue", + secondary=ProposalAllowedVenues, + backref="allowed_proposals", + ) allowed_times = db.Column(db.String, nullable=True) scheduled_duration = db.Column(db.Integer, nullable=True) scheduled_time: datetime = db.Column(db.DateTime, nullable=True) @@ -642,26 +636,12 @@ def has_ticket(self) -> bool: return admission_tickets > 0 or self.user.will_have_ticket def get_allowed_venues(self) -> list["Venue"]: - # FIXME: this should reference a foreign key instead if self.user_scheduled: - venue_names = [self.scheduled_venue.name] + return [self.scheduled_venue] elif self.allowed_venues: - venue_names = [v.strip() for v in self.allowed_venues.split(",")] + return self.allowed_venues else: - venue_names = DEFAULT_VENUES[self.type] - - if not venue_names: - return [] - - found = Venue.query.filter(Venue.name.in_(venue_names)).all() - # If we didn't actually find all the venues we're using, bail hard - if len(found) != len(venue_names): - raise InvalidVenueException("Invalid Venue in allowed_venues!") - - return found - - def get_allowed_venues_serialised(self): - return ",".join([v.name for v in self.get_allowed_venues()]) + return Venue.query.filter(Venue.default_for_types.any(self.type)).all() def fix_hard_time_limits(self, time_periods): # This should be fixed by the string periods being burned and replaced @@ -1055,8 +1035,10 @@ class Venue(BaseModel): db.Integer, db.ForeignKey("village.id"), nullable=True, default=None ) name = db.Column(db.String, nullable=False) - type = db.Column(db.String, nullable=True) + allowed_types = db.Column(MutableList.as_mutable(ARRAY(db.String))) + default_for_types = db.Column(MutableList.as_mutable(ARRAY(db.String))) priority = db.Column(db.Integer, nullable=True, default=0) + capacity = db.Column(db.Integer, nullable=True) location = db.Column(Geometry("POINT", srid=4326)) scheduled_content_only = db.Column(db.Boolean) village = db.relationship( @@ -1088,6 +1070,26 @@ def __geo_interface__(self): "geometry": location.__geo_interface__, } + @property + def is_emf_venue(self): + return bool(self.allowed_types) + + @classmethod + def emf_venues(cls): + return cls.query.filter(db.func.array_length(cls.allowed_types, 1) > 0).all() + + @classmethod + def emf_venue_names_by_type(cls): + unnest = db.func.unnest(cls.allowed_types).table_valued() + return { + type: venue_names + for venue_names, type in db.engine.execute( + db.select([db.func.array_agg(cls.name), unnest.column]) + .join(unnest, db.true()) + .group_by(unnest.column) + ) + } + @classmethod def get_by_name(cls, name): return cls.query.filter_by(name=name).one() @@ -1112,8 +1114,6 @@ def get_by_name(cls, name): "REMAP_SLOT_PERIODS", "EVENT_SPACING", "cfp_period", - "DEFAULT_VENUES", - "VENUE_CAPACITY", "proposal_slug", "timeslot_to_period", "make_periods_contiguous", diff --git a/templates/cfp_review/venues/edit.html b/templates/cfp_review/venues/edit.html index 100d42ca3..261a5a0db 100644 --- a/templates/cfp_review/venues/edit.html +++ b/templates/cfp_review/venues/edit.html @@ -10,6 +10,9 @@

Edit Venue {{ venue.name }}

{{ render_field(form.village_id) }} {{ render_field(form.scheduled_content_only) }} {{ render_field(form.latlon) }} + {{ render_field(form.capacity) }} + {{ render_field(form.allowed_types) }} + {{ render_field(form.default_for_types) }} {{ form.submit(class_="btn btn-success") }} {{ form.delete(class_="btn btn-danger") }} diff --git a/templates/cfp_review/venues/index.html b/templates/cfp_review/venues/index.html index daf166cae..998121a42 100644 --- a/templates/cfp_review/venues/index.html +++ b/templates/cfp_review/venues/index.html @@ -28,6 +28,9 @@

New Venue

{{ render_field(form.village_id) }} {{ render_field(form.scheduled_content_only) }} {{ render_field(form.latlon) }} + {{ render_field(form.capacity) }} + {{ render_field(form.allowed_types) }} + {{ render_field(form.default_for_types) }} {{ form.submit(class_="btn btn-success") }}