diff --git a/apps/cfp/schedule_tasks.py b/apps/cfp/schedule_tasks.py index 678073897..6f97baaa0 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 c7997d0b3..4ba4d257a 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 b32a9821b..c0cb41a0b 100644 --- a/apps/cfp_review/__init__.py +++ b/apps/cfp_review/__init__.py @@ -6,10 +6,10 @@ Proposal, CFPMessage, CFPVote, + Venue, CFP_STATES, ORDERED_STATES, HUMAN_CFP_TYPES, - DEFAULT_VENUES, ) from ..common import require_permission @@ -127,7 +127,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 376c87d98..fea2cc526 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 @@ -999,8 +1009,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 @@ -1118,11 +1131,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 1551940ce..9f1fdd7a9 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 d6b610360..f217de841 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" @@ -422,7 +412,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) @@ -644,26 +638,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 @@ -1057,8 +1037,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( @@ -1090,6 +1072,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() @@ -1114,8 +1116,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") }}