From ea3a58dfe29d3afcaaa30c9ccfafea960d8071a8 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Jul 2025 15:17:26 +0100 Subject: [PATCH 1/9] Add the Proposal model for the new content system This is just the columns to start sketching things out --- models/content/proposal.py | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 models/content/proposal.py diff --git a/models/content/proposal.py b/models/content/proposal.py new file mode 100644 index 000000000..9b24855db --- /dev/null +++ b/models/content/proposal.py @@ -0,0 +1,69 @@ +""" +Proposal – a proposed piece of content to be reviewed by the EMF team (this is +what the CfP form generates). Proposals now only track the review state - when +a proposal for a talk or workshop is accepted, a corresponding ScheduleItem +is created. +""" + +from datetime import datetime + +from main import db +from ..cfp_tag import ( + ProposalTag, +) # fixme copy into directory & make filename/classname match +from .. import BaseModel + + +UserProposal = db.Table( + "user_proposal", + BaseModel.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True), + db.Column( + "proposal_id", + db.Integer, + db.ForeignKey("proposal.id"), + primary_key=True, + index=True, + ), +) + + +class Proposal(BaseModel): + # FIXME remove _v2 + __tablename__ = "proposal_v2" + + id = db.Column(db.Integer, primary_key=True) + users = db.relationship( + "User", secondary=UserProposal, backref=db.backref("proposals") + ) + + type = db.Column(db.string, nullable=False) + state = db.Column(db.string, nullable=False, default="new") + + title = db.Column(db.string, nullable=False) + description = db.Column(db.string, nullable=False) + + length = db.Column(db.string) + needs_money = db.Column(db.Boolean, nullable=False, default=False) + needs_help = db.Column(db.Boolean, nullable=False, default=False) + private_notes = db.Column(db.string) + + tags = db.relationship( + "Tag", + backref="proposals", + cascade="all", + secondary=ProposalTag, + ) + + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + anonymiser_id = db.Column(db.Integer, db.ForeignKey("user.id"), default=None) + has_rejected_email = db.Column(db.Boolean, nullable=False, default=False) + + schedule_item = db.relationship("ScheduleItem", backref="proposal") + + messages = db.relationship("CFPMessage", backref="proposal") + votes = db.relationship("CFPVotes", backref="proposal") From 8fe8d8283bcdc05982ebdaf3f45a0fb1f2becb04 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Jul 2025 15:30:01 +0100 Subject: [PATCH 2/9] Add scheduleItem model again sketching out with basic columns --- models/content/schedule_item.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 models/content/schedule_item.py diff --git a/models/content/schedule_item.py b/models/content/schedule_item.py new file mode 100644 index 000000000..152ed1734 --- /dev/null +++ b/models/content/schedule_item.py @@ -0,0 +1,73 @@ +""" +ScheduleItem – a piece of content (official or otherwise) which can be +scheduled to happen at a specific time and place. It may have multiple owners. +""" + +from datetime import datetime + +from main import db +from .. import BaseModel +from .. import User + + +UserScheduleItem = db.Table( + "user_schedule_items", + BaseModel.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True), + db.Column( + "schedule_item_id", + db.Integer, + db.ForeignKey("schedule_item.id"), + primary_key=True, + index=True, + ), +) + +FavouriteProposal = db.Table( + "favourite_proposal", + BaseModel.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True), + db.Column( + "proposal_id", + db.Integer, + db.ForeignKey("proposal.id"), + primary_key=True, + index=True, + ), +) + + +class ScheduleItem(BaseModel): + __versioned__ = {"exclude": ["favourites"]} + __tablename__ = "schedule_item" + + id = db.Column(db.Integer, primary_key=True) + users = db.relationship( + User, secondary=UserScheduleItem, backref=db.backref("schedule_items") + ) + type = db.Column(db.string, nullable=False) + state = db.Column(db.string, nullable=False, default="new") + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + names = db.Column(db.string) + pronouns = db.Column(db.string) + title = db.Column(db.string, nullable=False) + description = db.Column(db.string, nullable=False) + + proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id")) + + duration = db.Column(db.string) + arrival_period = db.Column(db.string) + departure_period = db.Column(db.string) + available_times = db.Column(db.string) + + telephone_number = db.Column(db.string) + eventphone_number = db.Column(db.string) + + occurences = db.relationship("Occurrences", backref="schedule_item") + favourites = db.relationship( + User, secondary=FavouriteProposal, backref=db.backref("favourites") + ) From 70565366ad69d999ddc85c1ec7eeeb8347e5d4d6 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Jul 2025 16:09:18 +0100 Subject: [PATCH 3/9] Add Occurrence table --- models/content/occurrence.py | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 models/content/occurrence.py diff --git a/models/content/occurrence.py b/models/content/occurrence.py new file mode 100644 index 000000000..99cc964ac --- /dev/null +++ b/models/content/occurrence.py @@ -0,0 +1,37 @@ +""" +Occurrence – an instance of a ScheduleItem happening at a specific time in a +Venue (we provide for, but don’t currently actively support, multiple +Occurrences of a single ScheduleItem) +""" + +from datetime import datetime + +from main import db +from .. import BaseModel + + +class Occurrence(BaseModel): + __versioned__ = {} + __tablename__ = "occurrence" + + id = db.Column(db.Integer, primary_key=True) + schedule_item_id = db.Column( + db.Integer, db.ForeignKey("schedule_item.id", nullable=False) + ) + + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + potential_start_time = db.Column(db.String) + potential_time_block_id = db.Column(db.String) + scheduled_start_time = db.Column(db.String) + scheduled_time_block_id = db.Column(db.String) + + index = db.Column(db.Integer) + + c3voc_url = db.Column(db.String) + youtube_url = db.Column(db.String) + thumbnail_url = db.Column(db.String) + video_recording_lost = db.Column(db.Boolean, nullable=False, default=False) From 84e91a50274139ef568afa3b4dd8a669020fb7c8 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Jul 2025 16:15:13 +0100 Subject: [PATCH 4/9] Add schedule attribute table --- models/content/schedule_attribute.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 models/content/schedule_attribute.py diff --git a/models/content/schedule_attribute.py b/models/content/schedule_attribute.py new file mode 100644 index 000000000..96fd821f3 --- /dev/null +++ b/models/content/schedule_attribute.py @@ -0,0 +1,27 @@ +""" +ScheduleAttribute – key/value store of additional information associated with +a Proposal or ScheduleItem (e.g. workshop capacity, equipment required) +""" + +from datetime import datetime + +from main import db +from .. import BaseModel + + +class ScheduleAttribute(BaseModel): + __versioned__ = {} + __tablename__ = "schedule_attribute" + + id = db.Column(db.Integer, primary_key=True) + schedule_item_id = db.Column( + db.Integer, db.ForeignKey("schedule_item.id", nullable=False) + ) + + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + key = db.Column(db.string, nullable=False, index=True) + value = db.Column(db.string) From 0b6419c9a36fec800f12cc6247a93a476e4e8853 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Jul 2025 16:21:02 +0100 Subject: [PATCH 5/9] Add time block table --- models/content/occurrence.py | 4 ++++ models/content/time_block.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 models/content/time_block.py diff --git a/models/content/occurrence.py b/models/content/occurrence.py index 99cc964ac..c7d187ba2 100644 --- a/models/content/occurrence.py +++ b/models/content/occurrence.py @@ -19,6 +19,10 @@ class Occurrence(BaseModel): db.Integer, db.ForeignKey("schedule_item.id", nullable=False) ) + time_block_id = db.Column( + db.Integer, db.ForeignKey("time_block.id"), nullable=False + ) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) modified = db.Column( db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow diff --git a/models/content/time_block.py b/models/content/time_block.py new file mode 100644 index 000000000..1af8ce9f2 --- /dev/null +++ b/models/content/time_block.py @@ -0,0 +1,30 @@ +""" +TimeBlock – a block of time when occurrences of a specific content type are +allowed to be scheduled in a Venue. +""" + +from datetime import datetime + +from main import db +from .. import BaseModel + + +class TimeBlock(BaseModel): + __versioned__ = {} + __tablename__ = "time_block" + + id = db.Column(db.Integer, primary_key=True) + # FIXME move venue in to models/content + venue_id = db.Column(db.Integer, db.ForeignKey("venue.id"), nullable=False) + + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + start_time = db.Column(db.string, nullable=False) + duration = db.Column(db.string, nullable=False) + + allowed_types = db.Column(db.string) + + occurrences = db.relationship("Occurrence", backref="time_block") From 081df362c3bdbecf92f0ab79093075df9d12e9c4 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Jul 2025 17:04:26 +0100 Subject: [PATCH 6/9] Add the proposal attribute table There's a load of stuff that is only needed on some proposals (e.g. capacity for workshop proposals, size for installations). Whilst these will almost all get copied, 1:1 into schedule attributes if the proposal is accepted it's going to be a lot easier to keep them in their own table --- models/content/proposal.py | 2 -- models/content/proposal_attribute.py | 27 +++++++++++++++++++++++++++ models/content/schedule_attribute.py | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 models/content/proposal_attribute.py diff --git a/models/content/proposal.py b/models/content/proposal.py index 9b24855db..746f763ff 100644 --- a/models/content/proposal.py +++ b/models/content/proposal.py @@ -43,7 +43,6 @@ class Proposal(BaseModel): title = db.Column(db.string, nullable=False) description = db.Column(db.string, nullable=False) - length = db.Column(db.string) needs_money = db.Column(db.Boolean, nullable=False, default=False) needs_help = db.Column(db.Boolean, nullable=False, default=False) private_notes = db.Column(db.string) @@ -60,7 +59,6 @@ class Proposal(BaseModel): db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow ) - anonymiser_id = db.Column(db.Integer, db.ForeignKey("user.id"), default=None) has_rejected_email = db.Column(db.Boolean, nullable=False, default=False) schedule_item = db.relationship("ScheduleItem", backref="proposal") diff --git a/models/content/proposal_attribute.py b/models/content/proposal_attribute.py new file mode 100644 index 000000000..b987802de --- /dev/null +++ b/models/content/proposal_attribute.py @@ -0,0 +1,27 @@ +""" +ProposalAttribute – key/value store of additional information associated with +a Proposal (e.g. workshop capacity, equipment required) +""" + +from datetime import datetime + +from main import db +from .. import BaseModel + + +class ProposalAttribute(BaseModel): + __versioned__ = {} + __tablename__ = "proposal_attribute" + + id = db.Column(db.Integer, primary_key=True) + proposal_item_id = db.Column( + db.Integer, db.ForeignKey("proposal.id", nullable=False) + ) + + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + modified = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow + ) + + key = db.Column(db.string, nullable=False, index=True) + value = db.Column(db.string) diff --git a/models/content/schedule_attribute.py b/models/content/schedule_attribute.py index 96fd821f3..40fa62537 100644 --- a/models/content/schedule_attribute.py +++ b/models/content/schedule_attribute.py @@ -1,6 +1,6 @@ """ ScheduleAttribute – key/value store of additional information associated with -a Proposal or ScheduleItem (e.g. workshop capacity, equipment required) +a ScheduleItem (e.g. workshop capacity, equipment required) """ from datetime import datetime From 2a7e4d33a9b7b4bd170d5c4fcc01917a7d047263 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Jul 2025 17:25:33 +0100 Subject: [PATCH 7/9] Add the content type & content type field tables We need someway to configure different types of content (e.g. talk, workshop, installation) as well as specify any fields that we expect to see as attributes (e.g. length capacity & size). We also don't want this to be done by hardcoding them in python. This will hopefully let us specify types in e.g. JSON and load them then work with them dynamically --- models/content/content_type_definition.py | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 models/content/content_type_definition.py diff --git a/models/content/content_type_definition.py b/models/content/content_type_definition.py new file mode 100644 index 000000000..3442fe177 --- /dev/null +++ b/models/content/content_type_definition.py @@ -0,0 +1,63 @@ +""" +ContentTypeDefinition - we need a way to track which content types we have +defined and which attributes they expect to exist + +ContentTypeFieldDefinition - defines a field used by a content type. + +We assume that all proposal types will have a corresponding schedule item type. +""" + +from main import db +from .. import BaseModel + + +class ContentTypeDefinition(BaseModel): + __tablename__ = "content_type_definition" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, unique=True, index=True) + + fields = db.relationship("ContentTypeFieldDefinition", backref="type") + + def __init__(self, name: str): + self.name = name + + +class ContentTypeFieldDefinition(BaseModel): + __tablename__ = "content_type_field_definition" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False) + display_name = db.Column(db.String, nullable=False) + + # if default is Null/None it is a required field + default = db.Column(db.String, nullable=True) + + type_id = db.Column( + db.Integer, + db.ForeignKey("content_type_definition.id"), + nullable=False, + index=True, + ) + + def __init__( + self, + content_type: ContentTypeDefinition, + name: str, + display_name: str | None = None, + default: str | None = None, + ): + self.type_id = content_type.id + self.name = name.lower() + + if display_name is None: + self.display_name = name.lower() + else: + self.display_name = display_name + + if default is not None: + self.default = default + + @property + def is_required(self): + return self.default is None From d795cfad9f51c454bb76232cc0ffe0098c8115cf Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Jul 2025 18:08:21 +0100 Subject: [PATCH 8/9] [TMP] add migrations for content v2 tables --- .../8152e3d020c3_add_new_content_tables.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 migrations/versions/8152e3d020c3_add_new_content_tables.py diff --git a/migrations/versions/8152e3d020c3_add_new_content_tables.py b/migrations/versions/8152e3d020c3_add_new_content_tables.py new file mode 100644 index 000000000..76691feed --- /dev/null +++ b/migrations/versions/8152e3d020c3_add_new_content_tables.py @@ -0,0 +1,44 @@ +"""Add new content tables + +Revision ID: 8152e3d020c3 +Revises: 7249f66e2ae0 +Create Date: 2025-07-11 16:59:00.698575 + +""" + +# revision identifiers, used by Alembic. +revision = '8152e3d020c3' +down_revision = '7249f66e2ae0' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('content_type_definition', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_content_type_definition')) + ) + op.create_index(op.f('ix_content_type_definition_name'), 'content_type_definition', ['name'], unique=True) + op.create_table('content_type_field_definition', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.Column('default', sa.String(), nullable=True), + sa.Column('type_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['type_id'], ['content_type_definition.id'], name=op.f('fk_content_type_field_definition_type_id_content_type_definition')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_content_type_field_definition')) + ) + op.create_index(op.f('ix_content_type_field_definition_type_id'), 'content_type_field_definition', ['type_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_content_type_field_definition_type_id'), table_name='content_type_field_definition') + op.drop_table('content_type_field_definition') + op.drop_index(op.f('ix_content_type_definition_name'), table_name='content_type_definition') + op.drop_table('content_type_definition') + # ### end Alembic commands ### From 3ac38d5eccb147da5081b7b651c1e88c525dc3f3 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Jul 2025 18:08:58 +0100 Subject: [PATCH 9/9] Add content type import task --- apps/base/dev/content_types.json | 13 +++++++++++ apps/base/dev/tasks.py | 39 ++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 apps/base/dev/content_types.json diff --git a/apps/base/dev/content_types.json b/apps/base/dev/content_types.json new file mode 100644 index 000000000..3045eb2fc --- /dev/null +++ b/apps/base/dev/content_types.json @@ -0,0 +1,13 @@ +{ + "talk": { + "length": {"default": "30min", "display_name": "talk duration"} + }, + "workshop": { + "capacity": {"default": "15", "display_name": "workshop capacity"}, + "length": {"default": "1hour", "display_name": "workshop duration"}, + "ticketed": {"default": "False"} + }, + "installation": { + "size": {"default": "phone booth"} + } +} diff --git a/apps/base/dev/tasks.py b/apps/base/dev/tasks.py index 5539ad85b..69271180d 100644 --- a/apps/base/dev/tasks.py +++ b/apps/base/dev/tasks.py @@ -1,5 +1,8 @@ -""" Development CLI tasks """ +"""Development CLI tasks""" + +import json import click + from pendulum import Duration as Offset, parse from flask import current_app as app from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound @@ -14,10 +17,16 @@ from models.payment import BankAccount from models.site_state import SiteState, refresh_states from models.feature_flag import FeatureFlag, refresh_flags +from models.content.content_type_definition import ( + ContentTypeDefinition, + ContentTypeFieldDefinition, +) from . import dev_cli from .fake import FakeDataGenerator +CONTENT_TYPE_DEFINITIONS_FILE = "apps/base/dev/content_types.json" + @dev_cli.command("data") @click.pass_context @@ -56,7 +65,6 @@ def enable_cfp(): refresh_states() - @dev_cli.command("volunteer_data") def volunteer_data(): """Make fake volunteer system data""" @@ -632,3 +640,30 @@ def create_bank_accounts(): pass db.session.commit() + + +@dev_cli.command("create_content_types") +def create_content_types(): + app.logger.info(f"Adding content type definitions") + with open(CONTENT_TYPE_DEFINITIONS_FILE, "r") as file: + data = json.load(file) + + # FIXME make this update correctly + for content_type_name, type_field_defs in data.items(): + app.logger.info(f"Adding {content_type_name} definition") + content_type = ContentTypeDefinition(content_type_name) + db.session.add(content_type) + db.session.commit() + + for field_name, field_def in type_field_defs.items(): + db.session.add( + ContentTypeFieldDefinition( + content_type, + field_name, + field_def.get("display_name", None), + field_def.get("default", None), + ) + ) + db.session.commit() + db.session.flush() + app.logger.info("Done")