diff --git a/meta b/meta index 9f896db7b6..75ddd3b9e1 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit 9f896db7b6934c2b088a54c89700837995799959 +Subproject commit 75ddd3b9e14629051b6c2fc2b8adb55fb482d80b diff --git a/openslides_backend/migrations/data/__init__.py b/openslides_backend/migrations/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openslides_backend/migrations/data/mig_0100_resulting_models.py b/openslides_backend/migrations/data/mig_0100_resulting_models.py new file mode 100644 index 0000000000..4cd2dcaa3b --- /dev/null +++ b/openslides_backend/migrations/data/mig_0100_resulting_models.py @@ -0,0 +1,3021 @@ +from openslides_backend.models import fields +from openslides_backend.models.mixins import ( + AgendaItemModelMixin, + MeetingModelMixin, + PollModelMixin, +) + +from ..migration_models import MigrationModelCreateUpdate, MigrationModelRegistry + +model_registry_class = MigrationModelRegistry() + + +class Model(MigrationModelCreateUpdate): + _migration_registry = model_registry_class + + +class ActionWorker(Model): + collection = "action_worker" + verbose_name = "action worker" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + state = fields.CharField( + required=True, constraints={"enum": ["running", "end", "aborted"]} + ) + created = fields.TimestampField(required=True) + timestamp = fields.TimestampField(required=True) + result = fields.JSONField() + user_id = fields.IntegerField( + required=True, + constant=True, + constraints={ + "description": "Id of the calling user. If the action is called via internal route, the value will be -1." + }, + ) + + +class AgendaItem(Model, AgendaItemModelMixin): + collection = "agenda_item" + verbose_name = "agenda item" + + id = fields.IntegerField(required=True, constant=True) + item_number = fields.CharField() + comment = fields.CharField() + closed = fields.BooleanField(default=False) + type = fields.CharField( + default="common", constraints={"enum": ["common", "internal", "hidden"]} + ) + duration = fields.IntegerField( + constraints={"description": "Given in seconds", "minimum": 0} + ) + is_internal = fields.BooleanField( + read_only=True, constraints={"description": "Calculated by the server"} + ) + is_hidden = fields.BooleanField( + read_only=True, constraints={"description": "Calculated by the server"} + ) + level = fields.IntegerField( + read_only=True, constraints={"description": "Calculated by the server"} + ) + weight = fields.IntegerField() + content_object_id = fields.GenericRelationField( + to={ + "motion": "agenda_item_id", + "motion_block": "agenda_item_id", + "assignment": "agenda_item_id", + "topic": "agenda_item_id", + }, + required=True, + constant=True, + ) + parent_id = fields.RelationField(to={"agenda_item": "child_ids"}) + child_ids = fields.RelationListField( + to={"agenda_item": "parent_id"}, is_view_field=True + ) + tag_ids = fields.RelationListField( + to={"tag": "tagged_ids"}, + is_view_field=True, + write_fields=( + "gm_tag_tagged_ids_t", + "agenda_item_id", + "tag_id", + ["tag_id_tag_id"], + ), + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "agenda_item_ids"}, required=True, constant=True + ) + + +class Assignment(Model): + collection = "assignment" + verbose_name = "assignment" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField(required=True) + description = fields.HTMLStrictField() + open_posts = fields.IntegerField(default=0, constraints={"minimum": 0}) + phase = fields.CharField( + default="search", constraints={"enum": ["search", "voting", "finished"]} + ) + default_poll_description = fields.TextField() + number_poll_candidates = fields.BooleanField() + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + candidate_ids = fields.RelationListField( + to={"assignment_candidate": "assignment_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + poll_ids = fields.RelationListField( + to={"poll": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + agenda_item_id = fields.RelationField( + to={"agenda_item": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + required=True, + constant=True, + ) + tag_ids = fields.RelationListField( + to={"tag": "tagged_ids"}, + is_view_field=True, + write_fields=( + "gm_tag_tagged_ids_t", + "assignment_id", + "tag_id", + ["tag_id_tag_id"], + ), + ) + attachment_meeting_mediafile_ids = fields.RelationListField( + to={"meeting_mediafile": "attachment_ids"}, + is_view_field=True, + write_fields=( + "gm_meeting_mediafile_attachment_ids_t", + "assignment_id", + "attachment_meeting_mediafile_id", + ["attachment_meeting_mediafile_id_meeting_mediafile_id"], + ), + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "assignment_ids"}, required=True, constant=True + ) + history_entry_ids = fields.RelationListField( + to={"history_entry": "model_id"}, is_view_field=True + ) + + +class AssignmentCandidate(Model): + collection = "assignment_candidate" + verbose_name = "assignment candidate" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.IntegerField(default=10000) + assignment_id = fields.RelationField( + to={"assignment": "candidate_ids"}, required=True, constant=True + ) + meeting_user_id = fields.RelationField( + to={"meeting_user": "assignment_candidate_ids"}, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "assignment_candidate_ids"}, required=True, constant=True + ) + + +class ChatGroup(Model): + collection = "chat_group" + verbose_name = "chat group" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + weight = fields.IntegerField(default=10000) + chat_message_ids = fields.RelationListField( + to={"chat_message": "chat_group_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + read_group_ids = fields.RelationListField( + to={"group": "read_chat_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_chat_group_read_group_ids_group_t", + "chat_group_id", + "group_id", + [], + ), + ) + write_group_ids = fields.RelationListField( + to={"group": "write_chat_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_chat_group_write_group_ids_group_t", + "chat_group_id", + "group_id", + [], + ), + ) + meeting_id = fields.RelationField( + to={"meeting": "chat_group_ids"}, required=True, constant=True + ) + + +class ChatMessage(Model): + collection = "chat_message" + verbose_name = "chat message" + + id = fields.IntegerField(required=True, constant=True) + content = fields.HTMLStrictField(required=True) + created = fields.TimestampField(required=True) + meeting_user_id = fields.RelationField( + to={"meeting_user": "chat_message_ids"}, constant=True + ) + chat_group_id = fields.RelationField( + to={"chat_group": "chat_message_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "chat_message_ids"}, required=True, constant=True + ) + + +class Committee(Model): + collection = "committee" + verbose_name = "committee" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + description = fields.HTMLStrictField() + external_id = fields.CharField(unique=True) + meeting_ids = fields.RelationListField( + to={"meeting": "committee_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + default_meeting_id = fields.RelationField( + to={"meeting": "default_meeting_for_committee_id"} + ) + user_ids = fields.RelationListField( + to={"user": "committee_ids"}, + is_view_field=True, + read_only=True, + constraints={ + "description": "Calculated field: All users which are in a group of a meeting, belonging to the committee or beeing manager of the committee" + }, + ) + manager_ids = fields.RelationListField( + to={"user": "committee_management_ids"}, + is_view_field=True, + is_primary=True, + write_fields=("nm_committee_manager_ids_user_t", "committee_id", "user_id", []), + ) + parent_id = fields.RelationField(to={"committee": "child_ids"}) + child_ids = fields.RelationListField( + to={"committee": "parent_id"}, is_view_field=True + ) + all_parent_ids = fields.RelationListField( + to={"committee": "all_child_ids"}, + is_view_field=True, + write_fields=( + "nm_committee_all_child_ids_committee_t", + "all_child_id", + "all_parent_id", + [], + ), + ) + all_child_ids = fields.RelationListField( + to={"committee": "all_parent_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_committee_all_child_ids_committee_t", + "all_parent_id", + "all_child_id", + [], + ), + ) + native_user_ids = fields.RelationListField( + to={"user": "home_committee_id"}, is_view_field=True + ) + forward_to_committee_ids = fields.RelationListField( + to={"committee": "receive_forwardings_from_committee_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_committee_forward_to_committee_ids_committee_t", + "receive_forwardings_from_committee_id", + "forward_to_committee_id", + [], + ), + ) + receive_forwardings_from_committee_ids = fields.RelationListField( + to={"committee": "forward_to_committee_ids"}, + is_view_field=True, + write_fields=( + "nm_committee_forward_to_committee_ids_committee_t", + "forward_to_committee_id", + "receive_forwardings_from_committee_id", + [], + ), + ) + organization_tag_ids = fields.RelationListField( + to={"organization_tag": "tagged_ids"}, + is_view_field=True, + write_fields=( + "gm_organization_tag_tagged_ids_t", + "committee_id", + "organization_tag_id", + ["organization_tag_id_organization_tag_id"], + ), + ) + organization_id = fields.OrganizationField( + to={"organization": "committee_ids"}, required=True, constant=True, default=1 + ) + + +class Gender(Model): + collection = "gender" + verbose_name = "gender" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True, unique=True) + organization_id = fields.OrganizationField( + to={"organization": "gender_ids"}, required=True, constant=True, default=1 + ) + user_ids = fields.RelationListField( + to={"user": "gender_id"}, is_view_field=True, is_primary=True + ) + + +class Group(Model): + collection = "group" + verbose_name = "group" + + id = fields.IntegerField(required=True, constant=True) + external_id = fields.CharField() + name = fields.CharField(required=True) + permissions = fields.CharArrayField( + in_array_constraints={ + "enum": [ + "agenda_item.can_manage", + "agenda_item.can_see", + "agenda_item.can_see_internal", + "assignment.can_manage", + "assignment.can_manage_polls", + "assignment.can_nominate_other", + "assignment.can_nominate_self", + "assignment.can_see", + "chat.can_manage", + "list_of_speakers.can_be_speaker", + "list_of_speakers.can_manage", + "list_of_speakers.can_see", + "list_of_speakers.can_manage_moderator_notes", + "list_of_speakers.can_see_moderator_notes", + "mediafile.can_manage", + "mediafile.can_see", + "meeting.can_manage_logos_and_fonts", + "meeting.can_manage_settings", + "meeting.can_see_autopilot", + "meeting.can_see_frontpage", + "meeting.can_see_history", + "meeting.can_see_livestream", + "motion.can_create", + "motion.can_create_amendments", + "motion.can_forward", + "motion.can_manage", + "motion.can_manage_metadata", + "motion.can_manage_polls", + "motion.can_see", + "motion.can_see_internal", + "motion.can_see_origin", + "motion.can_support", + "poll.can_manage", + "poll.can_see_progress", + "projector.can_manage", + "projector.can_see", + "tag.can_manage", + "user.can_manage", + "user.can_manage_presence", + "user.can_see_sensitive_data", + "user.can_see", + "user.can_update", + "user.can_edit_own_delegation", + ], + "enum_name": "enum_group_permissions[]", + } + ) + weight = fields.IntegerField() + meeting_user_ids = fields.RelationListField( + to={"meeting_user": "group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_group_meeting_user_ids_meeting_user_t", + "group_id", + "meeting_user_id", + [], + ), + ) + default_group_for_meeting_id = fields.RelationField( + to={"meeting": "default_group_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + admin_group_for_meeting_id = fields.RelationField( + to={"meeting": "admin_group_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + anonymous_group_for_meeting_id = fields.RelationField( + to={"meeting": "anonymous_group_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + meeting_mediafile_access_group_ids = fields.RelationListField( + to={"meeting_mediafile": "access_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_group_mmagi_meeting_mediafile_t", + "group_id", + "meeting_mediafile_id", + [], + ), + ) + meeting_mediafile_inherited_access_group_ids = fields.RelationListField( + to={"meeting_mediafile": "inherited_access_group_ids"}, + is_view_field=True, + is_primary=True, + read_only=True, + constraints={"description": "Calculated field."}, + write_fields=( + "nm_group_mmiagi_meeting_mediafile_t", + "group_id", + "meeting_mediafile_id", + [], + ), + ) + read_comment_section_ids = fields.RelationListField( + to={"motion_comment_section": "read_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_group_read_comment_section_ids_motion_comment_section_t", + "group_id", + "motion_comment_section_id", + [], + ), + ) + write_comment_section_ids = fields.RelationListField( + to={"motion_comment_section": "write_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_group_write_comment_section_ids_motion_comment_section_t", + "group_id", + "motion_comment_section_id", + [], + ), + ) + read_chat_group_ids = fields.RelationListField( + to={"chat_group": "read_group_ids"}, + is_view_field=True, + write_fields=( + "nm_chat_group_read_group_ids_group_t", + "group_id", + "chat_group_id", + [], + ), + ) + write_chat_group_ids = fields.RelationListField( + to={"chat_group": "write_group_ids"}, + is_view_field=True, + write_fields=( + "nm_chat_group_write_group_ids_group_t", + "group_id", + "chat_group_id", + [], + ), + ) + poll_ids = fields.RelationListField( + to={"poll": "entitled_group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=("nm_group_poll_ids_poll_t", "group_id", "poll_id", []), + ) + used_as_motion_poll_default_id = fields.RelationField( + to={"meeting": "motion_poll_default_group_ids"} + ) + used_as_assignment_poll_default_id = fields.RelationField( + to={"meeting": "assignment_poll_default_group_ids"} + ) + used_as_topic_poll_default_id = fields.RelationField( + to={"meeting": "topic_poll_default_group_ids"} + ) + used_as_poll_default_id = fields.RelationField( + to={"meeting": "poll_default_group_ids"} + ) + meeting_id = fields.RelationField( + to={"meeting": "group_ids"}, required=True, constant=True + ) + + +class HistoryEntry(Model): + collection = "history_entry" + verbose_name = "history entry" + + id = fields.IntegerField(required=True, constant=True) + entries = fields.TextArrayField() + original_model_id = fields.CharField(constant=True) + model_id = fields.GenericRelationField( + to={ + "user": "history_entry_ids", + "motion": "history_entry_ids", + "assignment": "history_entry_ids", + } + ) + position_id = fields.RelationField( + to={"history_position": "entry_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField(to={"meeting": "relevant_history_entry_ids"}) + + +class HistoryPosition(Model): + collection = "history_position" + verbose_name = "history position" + + id = fields.IntegerField(required=True, constant=True) + timestamp = fields.TimestampField(read_only=True) + original_user_id = fields.IntegerField(constant=True) + user_id = fields.RelationField(to={"user": "history_position_ids"}) + entry_ids = fields.RelationListField( + to={"history_entry": "position_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + + +class ImportPreview(Model): + collection = "import_preview" + verbose_name = "import preview" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField( + required=True, + constraints={ + "enum": ["account", "participant", "topic", "committee", "motion"] + }, + ) + state = fields.CharField( + required=True, constraints={"enum": ["warning", "error", "done"]} + ) + created = fields.TimestampField(required=True) + result = fields.JSONField() + + +class ListOfSpeakers(Model): + collection = "list_of_speakers" + verbose_name = "list of speakers" + + id = fields.IntegerField(required=True, constant=True) + closed = fields.BooleanField(default=False) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + moderator_notes = fields.HTMLStrictField() + content_object_id = fields.GenericRelationField( + to={ + "motion": "list_of_speakers_id", + "motion_block": "list_of_speakers_id", + "assignment": "list_of_speakers_id", + "topic": "list_of_speakers_id", + "meeting_mediafile": "list_of_speakers_id", + }, + required=True, + constant=True, + ) + speaker_ids = fields.RelationListField( + to={"speaker": "list_of_speakers_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + structure_level_list_of_speakers_ids = fields.RelationListField( + to={"structure_level_list_of_speakers": "list_of_speakers_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "list_of_speakers_ids"}, required=True, constant=True + ) + + +class Mediafile(Model): + collection = "mediafile" + verbose_name = "mediafile" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField( + required=True, + constraints={"description": "Title and parent_id must be unique."}, + ) + is_directory = fields.BooleanField() + filesize = fields.IntegerField( + read_only=True, + constraints={"description": "In bytes, not the human readable format anymore."}, + ) + filename = fields.CharField( + constraints={ + "description": "The uploaded filename. Will be used for downloading. Only writeable on create." + } + ) + mimetype = fields.CharField() + pdf_information = fields.JSONField() + create_timestamp = fields.TimestampField() + token = fields.CharField(unique=True) + published_to_meetings_in_organization_id = fields.RelationField( + to={"organization": "published_mediafile_ids"} + ) + parent_id = fields.RelationField(to={"mediafile": "child_ids"}) + child_ids = fields.RelationListField( + to={"mediafile": "parent_id"}, is_view_field=True + ) + owner_id = fields.GenericRelationField( + to={"organization": "mediafile_ids", "meeting": "mediafile_ids"}, + required=True, + constant=True, + ) + meeting_mediafile_ids = fields.RelationListField( + to={"meeting_mediafile": "mediafile_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + + +class Meeting(Model, MeetingModelMixin): + collection = "meeting" + verbose_name = "meeting" + + id = fields.IntegerField(required=True, constant=True) + external_id = fields.CharField(unique=True) + welcome_title = fields.CharField(default="Welcome to OpenSlides") + welcome_text = fields.HTMLPermissiveField(default="Space for your welcome text.") + name = fields.CharField( + required=True, default="OpenSlides", constraints={"maxLength": 200} + ) + is_active_in_organization_id = fields.RelationField( + to={"organization": "active_meeting_ids"}, + constraints={"description": "Backrelation and boolean flag at once"}, + ) + is_archived_in_organization_id = fields.RelationField( + to={"organization": "archived_meeting_ids"}, + constraints={"description": "Backrelation and boolean flag at once"}, + ) + description = fields.CharField(constraints={"maxLength": 100}) + location = fields.CharField() + time_zone = fields.TimezoneField() + start_time = fields.TimestampField() + end_time = fields.TimestampField() + locked_from_inside = fields.BooleanField() + imported_at = fields.TimestampField() + language = fields.CharField( + constant=True, + default="en", + constraints={"enum": ["en", "de", "it", "es", "ru", "cs", "fr"]}, + ) + jitsi_domain = fields.CharField() + jitsi_room_name = fields.CharField() + jitsi_room_password = fields.CharField() + template_for_organization_id = fields.RelationField( + to={"organization": "template_meeting_ids"} + ) + enable_anonymous = fields.BooleanField(default=False) + custom_translations = fields.JSONField() + conference_show = fields.BooleanField(default=False) + conference_auto_connect = fields.BooleanField(default=False) + conference_los_restriction = fields.BooleanField(default=True) + conference_stream_url = fields.CharField() + conference_stream_poster_url = fields.CharField() + conference_open_microphone = fields.BooleanField(default=False) + conference_open_video = fields.BooleanField(default=False) + conference_auto_connect_next_speakers = fields.IntegerField(default=0) + conference_enable_helpdesk = fields.BooleanField(default=False) + applause_enable = fields.BooleanField(default=False) + applause_type = fields.CharField( + default="applause-type-bar", + constraints={"enum": ["applause-type-bar", "applause-type-particles"]}, + ) + applause_show_level = fields.BooleanField(default=False) + applause_min_amount = fields.IntegerField(default=1, constraints={"minimum": 0}) + applause_max_amount = fields.IntegerField(default=0, constraints={"minimum": 0}) + applause_timeout = fields.IntegerField(default=5, constraints={"minimum": 0}) + applause_particle_image_url = fields.CharField() + projector_countdown_default_time = fields.IntegerField(required=True, default=60) + projector_countdown_warning_time = fields.IntegerField( + required=True, default=0, constraints={"minimum": 0} + ) + export_csv_encoding = fields.CharField( + default="utf-8", constraints={"enum": ["utf-8", "iso-8859-15"]} + ) + export_csv_separator = fields.CharField(default=";") + export_pdf_pagenumber_alignment = fields.CharField( + default="center", constraints={"enum": ["left", "right", "center"]} + ) + export_pdf_fontsize = fields.IntegerField( + default=10, constraints={"minimum": 10, "maximum": 12} + ) + export_pdf_line_height = fields.FloatField( + default=1.25, constraints={"minimum": 1.0} + ) + export_pdf_page_margin_left = fields.IntegerField( + default=20, constraints={"minimum": 0} + ) + export_pdf_page_margin_top = fields.IntegerField( + default=25, constraints={"minimum": 0} + ) + export_pdf_page_margin_right = fields.IntegerField( + default=20, constraints={"minimum": 0} + ) + export_pdf_page_margin_bottom = fields.IntegerField( + default=20, constraints={"minimum": 0} + ) + export_pdf_pagesize = fields.CharField( + default="A4", constraints={"enum": ["A4", "A5"]} + ) + agenda_show_subtitles = fields.BooleanField(default=False) + agenda_enable_numbering = fields.BooleanField(default=True) + agenda_number_prefix = fields.CharField(constraints={"maxLength": 20}) + agenda_numeral_system = fields.CharField( + default="arabic", constraints={"enum": ["arabic", "roman"]} + ) + agenda_item_creation = fields.CharField( + default="default_no", + constraints={"enum": ["always", "never", "default_yes", "default_no"]}, + ) + agenda_new_items_default_visibility = fields.CharField( + default="internal", constraints={"enum": ["common", "internal", "hidden"]} + ) + agenda_show_internal_items_on_projector = fields.BooleanField(default=False) + agenda_show_topic_navigation_on_detail_view = fields.BooleanField(default=False) + list_of_speakers_amount_last_on_projector = fields.IntegerField( + default=0, constraints={"minimum": -1} + ) + list_of_speakers_amount_next_on_projector = fields.IntegerField( + default=-1, constraints={"minimum": -1} + ) + list_of_speakers_couple_countdown = fields.BooleanField(default=True) + list_of_speakers_show_amount_of_speakers_on_slide = fields.BooleanField( + default=True + ) + list_of_speakers_present_users_only = fields.BooleanField(default=False) + list_of_speakers_show_first_contribution = fields.BooleanField(default=False) + list_of_speakers_hide_contribution_count = fields.BooleanField(default=False) + list_of_speakers_allow_multiple_speakers = fields.BooleanField(default=False) + list_of_speakers_enable_point_of_order_speakers = fields.BooleanField(default=True) + list_of_speakers_can_create_point_of_order_for_others = fields.BooleanField( + default=False + ) + list_of_speakers_enable_point_of_order_categories = fields.BooleanField( + default=False + ) + list_of_speakers_closing_disables_point_of_order = fields.BooleanField( + default=False + ) + list_of_speakers_enable_pro_contra_speech = fields.BooleanField(default=False) + list_of_speakers_can_set_contribution_self = fields.BooleanField(default=False) + list_of_speakers_speaker_note_for_everyone = fields.BooleanField(default=True) + list_of_speakers_initially_closed = fields.BooleanField(default=False) + list_of_speakers_default_structure_level_time = fields.IntegerField( + constraints={ + "minimum": 0, + "description": "0 disables structure level countdowns.", + } + ) + list_of_speakers_enable_interposed_question = fields.BooleanField() + list_of_speakers_intervention_time = fields.IntegerField( + constraints={"description": "0 disables intervention speakers."} + ) + motions_default_workflow_id = fields.RelationField( + to={"motion_workflow": "default_workflow_meeting_id"}, required=True + ) + motions_default_amendment_workflow_id = fields.RelationField( + to={"motion_workflow": "default_amendment_workflow_meeting_id"}, required=True + ) + motions_preamble = fields.TextField(default="The assembly may decide:") + motions_default_line_numbering = fields.CharField( + default="outside", constraints={"enum": ["outside", "inline", "none"]} + ) + motions_line_length = fields.IntegerField(default=85, constraints={"minimum": 40}) + motions_reason_required = fields.BooleanField(default=False) + motions_origin_motion_toggle_default = fields.BooleanField(default=False) + motions_enable_origin_motion_display = fields.BooleanField(default=False) + motions_enable_text_on_projector = fields.BooleanField(default=True) + motions_enable_reason_on_projector = fields.BooleanField(default=False) + motions_enable_sidebox_on_projector = fields.BooleanField(default=False) + motions_enable_recommendation_on_projector = fields.BooleanField(default=True) + motions_hide_metadata_background = fields.BooleanField(default=False) + motions_show_referring_motions = fields.BooleanField(default=True) + motions_show_sequential_number = fields.BooleanField(default=True) + motions_create_enable_additional_submitter_text = fields.BooleanField() + motions_recommendations_by = fields.CharField() + motions_block_slide_columns = fields.IntegerField(constraints={"minimum": 1}) + motions_recommendation_text_mode = fields.CharField( + default="diff", constraints={"enum": ["original", "changed", "diff", "agreed"]} + ) + motions_default_sorting = fields.CharField( + default="number", constraints={"enum": ["number", "weight"]} + ) + motions_number_type = fields.CharField( + default="per_category", + constraints={"enum": ["per_category", "serially_numbered", "manually"]}, + ) + motions_number_min_digits = fields.IntegerField(default=2) + motions_number_with_blank = fields.BooleanField(default=False) + motions_amendments_enabled = fields.BooleanField(default=True) + motions_amendments_in_main_list = fields.BooleanField(default=True) + motions_amendments_of_amendments = fields.BooleanField(default=False) + motions_amendments_prefix = fields.CharField(default="-Ä") + motions_amendments_text_mode = fields.CharField( + default="paragraph", + constraints={"enum": ["freestyle", "fulltext", "paragraph"]}, + ) + motions_amendments_multiple_paragraphs = fields.BooleanField(default=True) + motions_supporters_min_amount = fields.IntegerField( + default=0, constraints={"minimum": 0} + ) + motions_enable_editor = fields.BooleanField() + motions_enable_working_group_speaker = fields.BooleanField() + motions_export_title = fields.CharField(default="Motions") + motions_export_preamble = fields.TextField() + motions_export_submitter_recommendation = fields.BooleanField(default=True) + motions_export_follow_recommendation = fields.BooleanField(default=False) + motions_enable_restricted_editor_for_manager = fields.BooleanField() + motions_enable_restricted_editor_for_non_manager = fields.BooleanField() + motion_poll_ballot_paper_selection = fields.CharField( + default="CUSTOM_NUMBER", + constraints={ + "enum": [ + "NUMBER_OF_DELEGATES", + "NUMBER_OF_ALL_PARTICIPANTS", + "CUSTOM_NUMBER", + ] + }, + ) + motion_poll_ballot_paper_number = fields.IntegerField(default=8) + motion_poll_default_type = fields.CharField(default="pseudoanonymous") + motion_poll_default_method = fields.CharField(default="YNA") + motion_poll_default_onehundred_percent_base = fields.CharField( + default="YNA", + constraints={ + "enum": [ + "Y", + "YN", + "YNA", + "N", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + motion_poll_default_group_ids = fields.RelationListField( + to={"group": "used_as_motion_poll_default_id"}, is_view_field=True + ) + motion_poll_default_backend = fields.CharField( + default="fast", constraints={"enum": ["long", "fast"]} + ) + motion_poll_projection_name_order_first = fields.CharField( + required=True, + default="last_name", + constraints={"enum": ["first_name", "last_name"]}, + ) + motion_poll_projection_max_columns = fields.IntegerField(required=True, default=6) + poll_candidate_list_ids = fields.RelationListField( + to={"poll_candidate_list": "meeting_id"}, is_view_field=True, is_primary=True + ) + poll_candidate_ids = fields.RelationListField( + to={"poll_candidate": "meeting_id"}, is_view_field=True, is_primary=True + ) + meeting_user_ids = fields.RelationListField( + to={"meeting_user": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + users_enable_presence_view = fields.BooleanField(default=False) + users_enable_vote_weight = fields.BooleanField(default=False) + users_allow_self_set_present = fields.BooleanField(default=True) + users_pdf_welcometitle = fields.CharField(default="Welcome to OpenSlides") + users_pdf_welcometext = fields.TextField( + default="[Place for your welcome and help text.]" + ) + users_pdf_wlan_ssid = fields.CharField() + users_pdf_wlan_password = fields.CharField() + users_pdf_wlan_encryption = fields.CharField( + default="WPA", constraints={"enum": ["", "WEP", "WPA", "nopass"]} + ) + users_email_sender = fields.CharField(default="OpenSlides") + users_email_replyto = fields.CharField() + users_email_subject = fields.CharField(default="OpenSlides access data") + users_email_body = fields.TextField( + default="Dear {name},\n\nthis is your personal OpenSlides login:\n\n{url}\nUsername: {username}\nPassword: {password}\n\n\nThis email was generated automatically." + ) + users_enable_vote_delegations = fields.BooleanField() + users_forbid_delegator_in_list_of_speakers = fields.BooleanField() + users_forbid_delegator_as_submitter = fields.BooleanField() + users_forbid_delegator_as_supporter = fields.BooleanField() + users_forbid_delegator_to_vote = fields.BooleanField() + assignments_export_title = fields.CharField(default="Elections") + assignments_export_preamble = fields.TextField() + assignment_poll_ballot_paper_selection = fields.CharField( + default="CUSTOM_NUMBER", + constraints={ + "enum": [ + "NUMBER_OF_DELEGATES", + "NUMBER_OF_ALL_PARTICIPANTS", + "CUSTOM_NUMBER", + ] + }, + ) + assignment_poll_ballot_paper_number = fields.IntegerField(default=8) + assignment_poll_add_candidates_to_list_of_speakers = fields.BooleanField( + default=False + ) + assignment_poll_enable_max_votes_per_option = fields.BooleanField(default=False) + assignment_poll_sort_poll_result_by_votes = fields.BooleanField(default=True) + assignment_poll_default_type = fields.CharField(default="pseudoanonymous") + assignment_poll_default_method = fields.CharField(default="Y") + assignment_poll_default_onehundred_percent_base = fields.CharField( + default="valid", + constraints={ + "enum": [ + "Y", + "YN", + "YNA", + "N", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + assignment_poll_default_group_ids = fields.RelationListField( + to={"group": "used_as_assignment_poll_default_id"}, is_view_field=True + ) + assignment_poll_default_backend = fields.CharField( + default="fast", constraints={"enum": ["long", "fast"]} + ) + poll_ballot_paper_selection = fields.CharField( + constraints={ + "enum": [ + "NUMBER_OF_DELEGATES", + "NUMBER_OF_ALL_PARTICIPANTS", + "CUSTOM_NUMBER", + ] + } + ) + poll_ballot_paper_number = fields.IntegerField() + poll_sort_poll_result_by_votes = fields.BooleanField() + poll_default_type = fields.CharField(default="analog") + poll_default_method = fields.CharField() + poll_default_onehundred_percent_base = fields.CharField( + default="YNA", + constraints={ + "enum": [ + "Y", + "YN", + "YNA", + "N", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + poll_default_group_ids = fields.RelationListField( + to={"group": "used_as_poll_default_id"}, is_view_field=True + ) + poll_default_backend = fields.CharField( + default="fast", constraints={"enum": ["long", "fast"]} + ) + poll_default_live_voting_enabled = fields.BooleanField( + default=False, + constraints={ + "description": "Defines default 'poll.live_voting_enabled' option suggested to user. Is not used in the validations." + }, + ) + poll_couple_countdown = fields.BooleanField(default=True) + topic_poll_default_group_ids = fields.RelationListField( + to={"group": "used_as_topic_poll_default_id"}, is_view_field=True + ) + projector_ids = fields.RelationListField( + to={"projector": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + all_projection_ids = fields.RelationListField( + to={"projection": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + projector_message_ids = fields.RelationListField( + to={"projector_message": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + projector_countdown_ids = fields.RelationListField( + to={"projector_countdown": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + tag_ids = fields.RelationListField( + to={"tag": "meeting_id"}, on_delete=fields.OnDelete.CASCADE, is_view_field=True + ) + agenda_item_ids = fields.RelationListField( + to={"agenda_item": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + list_of_speakers_ids = fields.RelationListField( + to={"list_of_speakers": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + structure_level_list_of_speakers_ids = fields.RelationListField( + to={"structure_level_list_of_speakers": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + point_of_order_category_ids = fields.RelationListField( + to={"point_of_order_category": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + speaker_ids = fields.RelationListField( + to={"speaker": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + topic_ids = fields.RelationListField( + to={"topic": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + group_ids = fields.RelationListField( + to={"group": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_mediafile_ids = fields.RelationListField( + to={"meeting_mediafile": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + mediafile_ids = fields.RelationListField( + to={"mediafile": "owner_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_ids = fields.RelationListField( + to={"motion": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + forwarded_motion_ids = fields.RelationListField( + to={"motion": "origin_meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_comment_section_ids = fields.RelationListField( + to={"motion_comment_section": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_category_ids = fields.RelationListField( + to={"motion_category": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_block_ids = fields.RelationListField( + to={"motion_block": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_workflow_ids = fields.RelationListField( + to={"motion_workflow": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_comment_ids = fields.RelationListField( + to={"motion_comment": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_submitter_ids = fields.RelationListField( + to={"motion_submitter": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_supporter_ids = fields.RelationListField( + to={"motion_supporter": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_editor_ids = fields.RelationListField( + to={"motion_editor": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_working_group_speaker_ids = fields.RelationListField( + to={"motion_working_group_speaker": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_change_recommendation_ids = fields.RelationListField( + to={"motion_change_recommendation": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + motion_state_ids = fields.RelationListField( + to={"motion_state": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + poll_ids = fields.RelationListField( + to={"poll": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + option_ids = fields.RelationListField( + to={"option": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + vote_ids = fields.RelationListField( + to={"vote": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + assignment_ids = fields.RelationListField( + to={"assignment": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + assignment_candidate_ids = fields.RelationListField( + to={"assignment_candidate": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + personal_note_ids = fields.RelationListField( + to={"personal_note": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + chat_group_ids = fields.RelationListField( + to={"chat_group": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + chat_message_ids = fields.RelationListField( + to={"chat_message": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + structure_level_ids = fields.RelationListField( + to={"structure_level": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + logo_projector_main_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_projector_main_in_meeting_id"} + ) + logo_projector_header_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_projector_header_in_meeting_id"} + ) + logo_web_header_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_web_header_in_meeting_id"} + ) + logo_pdf_header_l_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_pdf_header_l_in_meeting_id"} + ) + logo_pdf_header_r_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_pdf_header_r_in_meeting_id"} + ) + logo_pdf_footer_l_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_pdf_footer_l_in_meeting_id"} + ) + logo_pdf_footer_r_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_pdf_footer_r_in_meeting_id"} + ) + logo_pdf_ballot_paper_id = fields.RelationField( + to={"meeting_mediafile": "used_as_logo_pdf_ballot_paper_in_meeting_id"} + ) + font_regular_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_regular_in_meeting_id"} + ) + font_italic_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_italic_in_meeting_id"} + ) + font_bold_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_bold_in_meeting_id"} + ) + font_bold_italic_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_bold_italic_in_meeting_id"} + ) + font_monospace_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_monospace_in_meeting_id"} + ) + font_chyron_speaker_name_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_chyron_speaker_name_in_meeting_id"} + ) + font_projector_h1_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_projector_h1_in_meeting_id"} + ) + font_projector_h2_id = fields.RelationField( + to={"meeting_mediafile": "used_as_font_projector_h2_in_meeting_id"} + ) + committee_id = fields.RelationField( + to={"committee": "meeting_ids"}, required=True, constant=True + ) + default_meeting_for_committee_id = fields.RelationField( + to={"committee": "default_meeting_id"}, is_view_field=True + ) + organization_tag_ids = fields.RelationListField( + to={"organization_tag": "tagged_ids"}, + is_view_field=True, + write_fields=( + "gm_organization_tag_tagged_ids_t", + "meeting_id", + "organization_tag_id", + ["organization_tag_id_organization_tag_id"], + ), + ) + present_user_ids = fields.RelationListField( + to={"user": "is_present_in_meeting_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_meeting_present_user_ids_user_t", + "meeting_id", + "user_id", + [], + ), + ) + user_ids = fields.RelationListField( + to={"user": "meeting_ids"}, + is_view_field=True, + read_only=True, + constraints={ + "description": "Calculated. All user ids from all users assigned to groups of this meeting." + }, + ) + reference_projector_id = fields.RelationField( + to={"projector": "used_as_reference_projector_meeting_id"}, required=True + ) + list_of_speakers_countdown_id = fields.RelationField( + to={"projector_countdown": "used_as_list_of_speakers_countdown_meeting_id"} + ) + poll_countdown_id = fields.RelationField( + to={"projector_countdown": "used_as_poll_countdown_meeting_id"} + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + default_projector_agenda_item_list_ids = fields.RelationListField( + to={ + "projector": "used_as_default_projector_for_agenda_item_list_in_meeting_id" + }, + is_view_field=True, + required=True, + ) + default_projector_topic_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_topic_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_list_of_speakers_ids = fields.RelationListField( + to={ + "projector": "used_as_default_projector_for_list_of_speakers_in_meeting_id" + }, + is_view_field=True, + required=True, + ) + default_projector_current_los_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_current_los_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_motion_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_motion_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_amendment_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_amendment_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_motion_block_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_motion_block_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_assignment_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_assignment_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_mediafile_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_mediafile_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_message_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_message_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_countdown_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_countdown_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_assignment_poll_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_assignment_poll_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_motion_poll_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_motion_poll_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_projector_poll_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_poll_in_meeting_id"}, + is_view_field=True, + required=True, + ) + default_group_id = fields.RelationField( + to={"group": "default_group_for_meeting_id"}, required=True + ) + admin_group_id = fields.RelationField(to={"group": "admin_group_for_meeting_id"}) + anonymous_group_id = fields.RelationField( + to={"group": "anonymous_group_for_meeting_id"} + ) + relevant_history_entry_ids = fields.RelationListField( + to={"history_entry": "meeting_id"}, is_view_field=True + ) + + +class MeetingMediafile(Model): + collection = "meeting_mediafile" + verbose_name = "meeting mediafile" + + id = fields.IntegerField(required=True, constant=True) + mediafile_id = fields.RelationField( + to={"mediafile": "meeting_mediafile_ids"}, required=True + ) + meeting_id = fields.RelationField( + to={"meeting": "meeting_mediafile_ids"}, required=True, constant=True + ) + is_public = fields.BooleanField( + required=True, + constraints={ + "description": "Calculated in actions. Used to discern whether the (meeting-)mediafile can be seen by everyone, because, in the case of inherited_access_group_ids == [], it would otherwise not be clear. inherited_access_group_ids == [] can have two causes: cancelling access groups (=> is_public := false) or no access groups at all (=> is_public := true)" + }, + ) + inherited_access_group_ids = fields.RelationListField( + to={"group": "meeting_mediafile_inherited_access_group_ids"}, + is_view_field=True, + constraints={ + "description": "Calculated in actions. Shows what access group permissions are actually relevant. Calculated as the intersection of this meeting_mediafiles access_group_ids and the related mediafiles potential parent mediafiles inherited_access_group_ids. If the parent has no meeting_mediafile for this meeting, its inherited access group is assumed to be the meetings admin group. If there is no parent, the inherited_access_group_ids is equal to the access_group_ids. If the access_group_ids are empty, the interpretations is that every group has access rights, therefore the parent inherited_access_group_ids are used as-is." + }, + write_fields=( + "nm_group_mmiagi_meeting_mediafile_t", + "meeting_mediafile_id", + "group_id", + [], + ), + ) + access_group_ids = fields.RelationListField( + to={"group": "meeting_mediafile_access_group_ids"}, + is_view_field=True, + write_fields=( + "nm_group_mmagi_meeting_mediafile_t", + "meeting_mediafile_id", + "group_id", + [], + ), + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + attachment_ids = fields.GenericRelationListField( + to={ + "motion": "attachment_meeting_mediafile_ids", + "topic": "attachment_meeting_mediafile_ids", + "assignment": "attachment_meeting_mediafile_ids", + }, + is_view_field=True, + is_primary=True, + write_fields=( + "gm_meeting_mediafile_attachment_ids_t", + "meeting_mediafile_id", + "attachment_id", + [ + "attachment_id_motion_id", + "attachment_id_topic_id", + "attachment_id_assignment_id", + ], + ), + ) + used_as_logo_projector_main_in_meeting_id = fields.RelationField( + to={"meeting": "logo_projector_main_id"}, is_view_field=True + ) + used_as_logo_projector_header_in_meeting_id = fields.RelationField( + to={"meeting": "logo_projector_header_id"}, is_view_field=True + ) + used_as_logo_web_header_in_meeting_id = fields.RelationField( + to={"meeting": "logo_web_header_id"}, is_view_field=True + ) + used_as_logo_pdf_header_l_in_meeting_id = fields.RelationField( + to={"meeting": "logo_pdf_header_l_id"}, is_view_field=True + ) + used_as_logo_pdf_header_r_in_meeting_id = fields.RelationField( + to={"meeting": "logo_pdf_header_r_id"}, is_view_field=True + ) + used_as_logo_pdf_footer_l_in_meeting_id = fields.RelationField( + to={"meeting": "logo_pdf_footer_l_id"}, is_view_field=True + ) + used_as_logo_pdf_footer_r_in_meeting_id = fields.RelationField( + to={"meeting": "logo_pdf_footer_r_id"}, is_view_field=True + ) + used_as_logo_pdf_ballot_paper_in_meeting_id = fields.RelationField( + to={"meeting": "logo_pdf_ballot_paper_id"}, is_view_field=True + ) + used_as_font_regular_in_meeting_id = fields.RelationField( + to={"meeting": "font_regular_id"}, is_view_field=True + ) + used_as_font_italic_in_meeting_id = fields.RelationField( + to={"meeting": "font_italic_id"}, is_view_field=True + ) + used_as_font_bold_in_meeting_id = fields.RelationField( + to={"meeting": "font_bold_id"}, is_view_field=True + ) + used_as_font_bold_italic_in_meeting_id = fields.RelationField( + to={"meeting": "font_bold_italic_id"}, is_view_field=True + ) + used_as_font_monospace_in_meeting_id = fields.RelationField( + to={"meeting": "font_monospace_id"}, is_view_field=True + ) + used_as_font_chyron_speaker_name_in_meeting_id = fields.RelationField( + to={"meeting": "font_chyron_speaker_name_id"}, is_view_field=True + ) + used_as_font_projector_h1_in_meeting_id = fields.RelationField( + to={"meeting": "font_projector_h1_id"}, is_view_field=True + ) + used_as_font_projector_h2_in_meeting_id = fields.RelationField( + to={"meeting": "font_projector_h2_id"}, is_view_field=True + ) + + +class MeetingUser(Model): + collection = "meeting_user" + verbose_name = "meeting user" + + id = fields.IntegerField(required=True, constant=True) + comment = fields.HTMLStrictField() + number = fields.CharField() + about_me = fields.HTMLStrictField() + vote_weight = fields.DecimalField(constraints={"minimum": "0.000001"}) + locked_out = fields.BooleanField() + user_id = fields.RelationField( + to={"user": "meeting_user_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "meeting_user_ids"}, required=True, constant=True + ) + personal_note_ids = fields.RelationListField( + to={"personal_note": "meeting_user_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + speaker_ids = fields.RelationListField( + to={"speaker": "meeting_user_id"}, is_view_field=True + ) + motion_supporter_ids = fields.RelationListField( + to={"motion_supporter": "meeting_user_id"}, is_view_field=True + ) + motion_editor_ids = fields.RelationListField( + to={"motion_editor": "meeting_user_id"}, is_view_field=True + ) + motion_working_group_speaker_ids = fields.RelationListField( + to={"motion_working_group_speaker": "meeting_user_id"}, is_view_field=True + ) + motion_submitter_ids = fields.RelationListField( + to={"motion_submitter": "meeting_user_id"}, is_view_field=True + ) + assignment_candidate_ids = fields.RelationListField( + to={"assignment_candidate": "meeting_user_id"}, is_view_field=True + ) + vote_delegated_to_id = fields.RelationField( + to={"meeting_user": "vote_delegations_from_ids"} + ) + vote_delegations_from_ids = fields.RelationListField( + to={"meeting_user": "vote_delegated_to_id"}, is_view_field=True + ) + chat_message_ids = fields.RelationListField( + to={"chat_message": "meeting_user_id"}, is_view_field=True + ) + group_ids = fields.RelationListField( + to={"group": "meeting_user_ids"}, + is_view_field=True, + required=True, + write_fields=( + "nm_group_meeting_user_ids_meeting_user_t", + "meeting_user_id", + "group_id", + [], + ), + ) + structure_level_ids = fields.RelationListField( + to={"structure_level": "meeting_user_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_meeting_user_structure_level_ids_structure_level_t", + "meeting_user_id", + "structure_level_id", + [], + ), + ) + + +class Motion(Model): + collection = "motion" + verbose_name = "motion" + + id = fields.IntegerField(required=True, constant=True) + number = fields.CharField() + number_value = fields.IntegerField( + read_only=True, + constraints={ + "description": "The number value of this motion. This number is auto-generated and read-only." + }, + ) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + title = fields.CharField(required=True) + diff_version = fields.CharField() + text = fields.HTMLStrictField() + text_hash = fields.CharField() + amendment_paragraphs = fields.JSONField() + modified_final_version = fields.HTMLStrictField() + reason = fields.HTMLStrictField() + category_weight = fields.IntegerField(default=10000) + state_extension = fields.CharField() + recommendation_extension = fields.CharField() + sort_weight = fields.IntegerField(default=10000) + created = fields.TimestampField() + last_modified = fields.TimestampField(read_only=True) + workflow_timestamp = fields.TimestampField() + start_line_number = fields.IntegerField(default=1, constraints={"minimum": 1}) + forwarded = fields.TimestampField(read_only=True) + additional_submitter = fields.CharField() + marked_forwarded = fields.BooleanField( + constraints={ + "description": "Forwarded amendments can be marked as such. This is just optional, however. Forwarded amendments can also have this field set to false." + } + ) + lead_motion_id = fields.RelationField(to={"motion": "amendment_ids"}) + amendment_ids = fields.RelationListField( + to={"motion": "lead_motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + sort_parent_id = fields.RelationField(to={"motion": "sort_child_ids"}) + sort_child_ids = fields.RelationListField( + to={"motion": "sort_parent_id"}, is_view_field=True + ) + origin_id = fields.RelationField(to={"motion": "derived_motion_ids"}) + origin_meeting_id = fields.RelationField(to={"meeting": "forwarded_motion_ids"}) + derived_motion_ids = fields.RelationListField( + to={"motion": "origin_id"}, is_view_field=True + ) + all_origin_ids = fields.RelationListField( + to={"motion": "all_derived_motion_ids"}, + is_view_field=True, + write_fields=( + "nm_motion_all_derived_motion_ids_motion_t", + "all_derived_motion_id", + "all_origin_id", + [], + ), + ) + all_derived_motion_ids = fields.RelationListField( + to={"motion": "all_origin_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_motion_all_derived_motion_ids_motion_t", + "all_origin_id", + "all_derived_motion_id", + [], + ), + ) + identical_motion_ids = fields.RelationListField( + to={"motion": "identical_motion_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_motion_identical_motion_ids_motion_t", + "identical_motion_id_1", + "identical_motion_id_2", + [], + ), + ) + state_id = fields.RelationField(to={"motion_state": "motion_ids"}, required=True) + recommendation_id = fields.RelationField( + to={"motion_state": "motion_recommendation_ids"} + ) + state_extension_reference_ids = fields.GenericRelationListField( + to={"motion": "referenced_in_motion_state_extension_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "gm_motion_state_extension_reference_ids_t", + "motion_id", + "state_extension_reference_id", + ["state_extension_reference_id_motion_id"], + ), + ) + referenced_in_motion_state_extension_ids = fields.RelationListField( + to={"motion": "state_extension_reference_ids"}, + is_view_field=True, + write_fields=( + "gm_motion_state_extension_reference_ids_t", + "motion_id", + "referenced_in_motion_state_extension_id", + ["referenced_in_motion_state_extension_id_motion_id"], + ), + ) + recommendation_extension_reference_ids = fields.GenericRelationListField( + to={"motion": "referenced_in_motion_recommendation_extension_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "gm_motion_recommendation_extension_reference_ids_t", + "motion_id", + "recommendation_extension_reference_id", + ["recommendation_extension_reference_id_motion_id"], + ), + ) + referenced_in_motion_recommendation_extension_ids = fields.RelationListField( + to={"motion": "recommendation_extension_reference_ids"}, + is_view_field=True, + write_fields=( + "gm_motion_recommendation_extension_reference_ids_t", + "motion_id", + "referenced_in_motion_recommendation_extension_id", + ["referenced_in_motion_recommendation_extension_id_motion_id"], + ), + ) + category_id = fields.RelationField(to={"motion_category": "motion_ids"}) + block_id = fields.RelationField(to={"motion_block": "motion_ids"}) + submitter_ids = fields.RelationListField( + to={"motion_submitter": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + supporter_ids = fields.RelationListField( + to={"motion_supporter": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + editor_ids = fields.RelationListField( + to={"motion_editor": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + working_group_speaker_ids = fields.RelationListField( + to={"motion_working_group_speaker": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + poll_ids = fields.RelationListField( + to={"poll": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + option_ids = fields.RelationListField( + to={"option": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + change_recommendation_ids = fields.RelationListField( + to={"motion_change_recommendation": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + comment_ids = fields.RelationListField( + to={"motion_comment": "motion_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + agenda_item_id = fields.RelationField( + to={"agenda_item": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + required=True, + constant=True, + ) + tag_ids = fields.RelationListField( + to={"tag": "tagged_ids"}, + is_view_field=True, + write_fields=("gm_tag_tagged_ids_t", "motion_id", "tag_id", ["tag_id_tag_id"]), + ) + attachment_meeting_mediafile_ids = fields.RelationListField( + to={"meeting_mediafile": "attachment_ids"}, + is_view_field=True, + write_fields=( + "gm_meeting_mediafile_attachment_ids_t", + "motion_id", + "attachment_meeting_mediafile_id", + ["attachment_meeting_mediafile_id_meeting_mediafile_id"], + ), + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + personal_note_ids = fields.RelationListField( + to={"personal_note": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_ids"}, required=True, constant=True + ) + history_entry_ids = fields.RelationListField( + to={"history_entry": "model_id"}, is_view_field=True + ) + + +class MotionBlock(Model): + collection = "motion_block" + verbose_name = "motion block" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField(required=True) + internal = fields.BooleanField() + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + motion_ids = fields.RelationListField(to={"motion": "block_id"}, is_view_field=True) + agenda_item_id = fields.RelationField( + to={"agenda_item": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + required=True, + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_block_ids"}, required=True, constant=True + ) + + +class MotionCategory(Model): + collection = "motion_category" + verbose_name = "motion category" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + prefix = fields.CharField() + weight = fields.IntegerField(default=10000) + level = fields.IntegerField( + read_only=True, constraints={"description": "Calculated field."} + ) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + parent_id = fields.RelationField(to={"motion_category": "child_ids"}) + child_ids = fields.RelationListField( + to={"motion_category": "parent_id"}, is_view_field=True + ) + motion_ids = fields.RelationListField( + to={"motion": "category_id"}, is_view_field=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_category_ids"}, required=True, constant=True + ) + + +class MotionChangeRecommendation(Model): + collection = "motion_change_recommendation" + verbose_name = "motion change recommendation" + + id = fields.IntegerField(required=True, constant=True) + rejected = fields.BooleanField(default=False) + internal = fields.BooleanField(default=False) + type = fields.CharField( + default="replacement", + constraints={"enum": ["replacement", "insertion", "deletion", "other"]}, + ) + other_description = fields.CharField() + line_from = fields.IntegerField(required=True, constraints={"minimum": 0}) + line_to = fields.IntegerField(required=True, constraints={"minimum": 0}) + text = fields.HTMLStrictField() + creation_time = fields.TimestampField(read_only=True) + motion_id = fields.RelationField( + to={"motion": "change_recommendation_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_change_recommendation_ids"}, required=True, constant=True + ) + + +class MotionComment(Model): + collection = "motion_comment" + verbose_name = "motion comment" + + id = fields.IntegerField(required=True, constant=True) + comment = fields.HTMLStrictField(required=True) + motion_id = fields.RelationField( + to={"motion": "comment_ids"}, required=True, constant=True + ) + section_id = fields.RelationField( + to={"motion_comment_section": "comment_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_comment_ids"}, required=True, constant=True + ) + + +class MotionCommentSection(Model): + collection = "motion_comment_section" + verbose_name = "motion comment section" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + weight = fields.IntegerField(default=10000) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + submitter_can_write = fields.BooleanField() + comment_ids = fields.RelationListField( + to={"motion_comment": "section_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + read_group_ids = fields.RelationListField( + to={"group": "read_comment_section_ids"}, + is_view_field=True, + write_fields=( + "nm_group_read_comment_section_ids_motion_comment_section_t", + "motion_comment_section_id", + "group_id", + [], + ), + ) + write_group_ids = fields.RelationListField( + to={"group": "write_comment_section_ids"}, + is_view_field=True, + write_fields=( + "nm_group_write_comment_section_ids_motion_comment_section_t", + "motion_comment_section_id", + "group_id", + [], + ), + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_comment_section_ids"}, required=True, constant=True + ) + + +class MotionEditor(Model): + collection = "motion_editor" + verbose_name = "motion editor" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.IntegerField() + meeting_user_id = fields.RelationField(to={"meeting_user": "motion_editor_ids"}) + motion_id = fields.RelationField( + to={"motion": "editor_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_editor_ids"}, required=True, constant=True + ) + + +class MotionState(Model): + collection = "motion_state" + verbose_name = "motion state" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + weight = fields.IntegerField(required=True) + recommendation_label = fields.CharField() + is_internal = fields.BooleanField() + css_class = fields.CharField( + required=True, + default="lightblue", + constraints={"enum": ["grey", "red", "green", "lightblue", "yellow"]}, + ) + restrictions = fields.CharArrayField( + default=[], + in_array_constraints={ + "enum": [ + "motion.can_see_internal", + "motion.can_manage_metadata", + "motion.can_manage", + "is_submitter", + ], + "enum_name": "enum_motion_state_restrictions[]", + }, + ) + allow_support = fields.BooleanField(default=False) + allow_create_poll = fields.BooleanField(default=False) + allow_submitter_edit = fields.BooleanField(default=False) + set_number = fields.BooleanField(default=True) + show_state_extension_field = fields.BooleanField(default=False) + show_recommendation_extension_field = fields.BooleanField(default=False) + merge_amendment_into_final = fields.CharField( + default="undefined", + constraints={"enum": ["do_not_merge", "undefined", "do_merge"]}, + ) + allow_motion_forwarding = fields.BooleanField(default=False) + allow_amendment_forwarding = fields.BooleanField() + set_workflow_timestamp = fields.BooleanField(default=False) + state_button_label = fields.CharField() + submitter_withdraw_state_id = fields.RelationField( + to={"motion_state": "submitter_withdraw_back_ids"} + ) + submitter_withdraw_back_ids = fields.RelationListField( + to={"motion_state": "submitter_withdraw_state_id"}, is_view_field=True + ) + next_state_ids = fields.RelationListField( + to={"motion_state": "previous_state_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_motion_state_next_state_ids_motion_state_t", + "previous_state_id", + "next_state_id", + [], + ), + ) + previous_state_ids = fields.RelationListField( + to={"motion_state": "next_state_ids"}, + is_view_field=True, + write_fields=( + "nm_motion_state_next_state_ids_motion_state_t", + "next_state_id", + "previous_state_id", + [], + ), + ) + motion_ids = fields.RelationListField( + to={"motion": "state_id"}, on_delete=fields.OnDelete.PROTECT, is_view_field=True + ) + motion_recommendation_ids = fields.RelationListField( + to={"motion": "recommendation_id"}, is_view_field=True + ) + workflow_id = fields.RelationField( + to={"motion_workflow": "state_ids"}, required=True + ) + first_state_of_workflow_id = fields.RelationField( + to={"motion_workflow": "first_state_id"}, + on_delete=fields.OnDelete.PROTECT, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_state_ids"}, required=True, constant=True + ) + + +class MotionSubmitter(Model): + collection = "motion_submitter" + verbose_name = "motion submitter" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.IntegerField() + meeting_user_id = fields.RelationField(to={"meeting_user": "motion_submitter_ids"}) + motion_id = fields.RelationField( + to={"motion": "submitter_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_submitter_ids"}, required=True, constant=True + ) + + +class MotionSupporter(Model): + collection = "motion_supporter" + verbose_name = "motion supporter" + + id = fields.IntegerField(required=True, constant=True) + meeting_user_id = fields.RelationField(to={"meeting_user": "motion_supporter_ids"}) + motion_id = fields.RelationField( + to={"motion": "supporter_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_supporter_ids"}, required=True, constant=True + ) + + +class MotionWorkflow(Model): + collection = "motion_workflow" + verbose_name = "motion workflow" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + state_ids = fields.RelationListField( + to={"motion_state": "workflow_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + first_state_id = fields.RelationField( + to={"motion_state": "first_state_of_workflow_id"}, required=True + ) + default_workflow_meeting_id = fields.RelationField( + to={"meeting": "motions_default_workflow_id"}, is_view_field=True + ) + default_amendment_workflow_meeting_id = fields.RelationField( + to={"meeting": "motions_default_amendment_workflow_id"}, is_view_field=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_workflow_ids"}, required=True, constant=True + ) + + +class MotionWorkingGroupSpeaker(Model): + collection = "motion_working_group_speaker" + verbose_name = "motion working group speaker" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.IntegerField() + meeting_user_id = fields.RelationField( + to={"meeting_user": "motion_working_group_speaker_ids"} + ) + motion_id = fields.RelationField( + to={"motion": "working_group_speaker_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "motion_working_group_speaker_ids"}, required=True, constant=True + ) + + +class Option(Model): + collection = "option" + verbose_name = "option" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.IntegerField(default=10000) + text = fields.HTMLStrictField() + yes = fields.DecimalField() + no = fields.DecimalField() + abstain = fields.DecimalField() + poll_id = fields.RelationField(to={"poll": "option_ids"}, constant=True) + used_as_global_option_in_poll_id = fields.RelationField( + to={"poll": "global_option_id"}, is_view_field=True, constant=True + ) + vote_ids = fields.RelationListField( + to={"vote": "option_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + content_object_id = fields.GenericRelationField( + to={ + "poll_candidate_list": "option_id", + "user": "option_ids", + "motion": "option_ids", + }, + constant=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "option_ids"}, required=True, constant=True + ) + + +class Organization(Model): + collection = "organization" + verbose_name = "organization" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField() + description = fields.HTMLStrictField() + legal_notice = fields.TextField() + privacy_policy = fields.TextField() + login_text = fields.TextField() + reset_password_verbose_errors = fields.BooleanField() + gender_ids = fields.RelationListField( + to={"gender": "organization_id"}, is_view_field=True, is_primary=True + ) + disable_forward_with_attachments = fields.BooleanField() + restrict_edit_forward_committees = fields.BooleanField() + enable_electronic_voting = fields.BooleanField() + enable_chat = fields.BooleanField() + limit_of_meetings = fields.IntegerField( + default=0, + constraints={ + "description": "Maximum of active meetings for the whole organization. 0 means no limitation at all", + "minimum": 0, + }, + ) + limit_of_users = fields.IntegerField( + default=0, + constraints={ + "description": "Maximum of active users for the whole organization. 0 means no limitation at all", + "minimum": 0, + }, + ) + default_language = fields.CharField( + default="en", constraints={"enum": ["en", "de", "it", "es", "ru", "cs", "fr"]} + ) + time_zone = fields.TimezoneField() + require_duplicate_from = fields.BooleanField() + enable_anonymous = fields.BooleanField() + restrict_editing_same_level_committee_admins = fields.BooleanField() + saml_enabled = fields.BooleanField() + saml_login_button_text = fields.CharField(default="SAML login") + saml_attr_mapping = fields.JSONField() + saml_metadata_idp = fields.TextField() + saml_metadata_sp = fields.TextField() + saml_private_key = fields.TextField() + committee_ids = fields.RelationListField( + to={"committee": "organization_id"}, is_view_field=True, is_primary=True + ) + active_meeting_ids = fields.RelationListField( + to={"meeting": "is_active_in_organization_id"}, is_view_field=True + ) + archived_meeting_ids = fields.RelationListField( + to={"meeting": "is_archived_in_organization_id"}, is_view_field=True + ) + template_meeting_ids = fields.RelationListField( + to={"meeting": "template_for_organization_id"}, is_view_field=True + ) + organization_tag_ids = fields.RelationListField( + to={"organization_tag": "organization_id"}, is_view_field=True, is_primary=True + ) + theme_id = fields.RelationField( + to={"theme": "theme_for_organization_id"}, required=True + ) + theme_ids = fields.RelationListField( + to={"theme": "organization_id"}, is_view_field=True, is_primary=True + ) + mediafile_ids = fields.RelationListField( + to={"mediafile": "owner_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + published_mediafile_ids = fields.RelationListField( + to={"mediafile": "published_to_meetings_in_organization_id"}, is_view_field=True + ) + user_ids = fields.RelationListField( + to={"user": "organization_id"}, is_view_field=True, is_primary=True + ) + users_email_sender = fields.CharField(default="OpenSlides") + users_email_replyto = fields.CharField() + users_email_subject = fields.CharField(default="OpenSlides access data") + users_email_body = fields.TextField( + default="Dear {name},\n\nthis is your personal OpenSlides login:\n\n{url}\nUsername: {username}\nPassword: {password}\n\n\nThis email was generated automatically." + ) + url = fields.CharField(default="https://example.com") + + +class OrganizationTag(Model): + collection = "organization_tag" + verbose_name = "organization tag" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + color = fields.ColorField(required=True) + tagged_ids = fields.GenericRelationListField( + to={"committee": "organization_tag_ids", "meeting": "organization_tag_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "gm_organization_tag_tagged_ids_t", + "organization_tag_id", + "tagged_id", + ["tagged_id_committee_id", "tagged_id_meeting_id"], + ), + ) + organization_id = fields.OrganizationField( + to={"organization": "organization_tag_ids"}, + required=True, + constant=True, + default=1, + ) + + +class PersonalNote(Model): + collection = "personal_note" + verbose_name = "personal note" + + id = fields.IntegerField(required=True, constant=True) + note = fields.HTMLStrictField() + star = fields.BooleanField() + meeting_user_id = fields.RelationField( + to={"meeting_user": "personal_note_ids"}, required=True, constant=True + ) + content_object_id = fields.GenericRelationField( + to={"motion": "personal_note_ids"}, required=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "personal_note_ids"}, required=True, constant=True + ) + + +class PointOfOrderCategory(Model): + collection = "point_of_order_category" + verbose_name = "point of order category" + + id = fields.IntegerField(required=True, constant=True) + text = fields.CharField(required=True) + rank = fields.IntegerField(required=True) + meeting_id = fields.RelationField( + to={"meeting": "point_of_order_category_ids"}, required=True, constant=True + ) + speaker_ids = fields.RelationListField( + to={"speaker": "point_of_order_category_id"}, is_view_field=True + ) + + +class Poll(Model, PollModelMixin): + collection = "poll" + verbose_name = "poll" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField(required=True) + description = fields.CharField() + type = fields.CharField( + required=True, + constraints={"enum": ["analog", "named", "pseudoanonymous", "cryptographic"]}, + ) + backend = fields.CharField( + required=True, default="fast", constraints={"enum": ["long", "fast"]} + ) + is_pseudoanonymized = fields.BooleanField() + pollmethod = fields.CharField( + required=True, constraints={"enum": ["Y", "YN", "YNA", "N"]} + ) + state = fields.CharField( + default="created", + constraints={"enum": ["created", "started", "finished", "published"]}, + ) + min_votes_amount = fields.IntegerField(default=1, constraints={"minimum": 1}) + max_votes_amount = fields.IntegerField(default=1, constraints={"minimum": 1}) + max_votes_per_option = fields.IntegerField(default=1, constraints={"minimum": 1}) + global_yes = fields.BooleanField(default=False) + global_no = fields.BooleanField(default=False) + global_abstain = fields.BooleanField(default=False) + onehundred_percent_base = fields.CharField( + required=True, + default="disabled", + constraints={ + "enum": [ + "Y", + "YN", + "YNA", + "N", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + votesvalid = fields.DecimalField() + votesinvalid = fields.DecimalField() + votescast = fields.DecimalField() + entitled_users_at_stop = fields.JSONField() + live_voting_enabled = fields.BooleanField( + default=False, + constraints={ + "description": "If true, the vote service sends the votes of the users to the autoupdate service." + }, + ) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + content_object_id = fields.GenericRelationField( + to={"motion": "poll_ids", "assignment": "poll_ids", "topic": "poll_ids"}, + required=True, + constant=True, + ) + option_ids = fields.RelationListField( + to={"option": "poll_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + global_option_id = fields.RelationField( + to={"option": "used_as_global_option_in_poll_id"}, + on_delete=fields.OnDelete.CASCADE, + constant=True, + ) + voted_ids = fields.RelationListField( + to={"user": "poll_voted_ids"}, + is_view_field=True, + is_primary=True, + write_fields=("nm_poll_voted_ids_user_t", "poll_id", "user_id", []), + ) + entitled_group_ids = fields.RelationListField( + to={"group": "poll_ids"}, + is_view_field=True, + write_fields=("nm_group_poll_ids_poll_t", "poll_id", "group_id", []), + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "poll_ids"}, required=True, constant=True + ) + + +class PollCandidate(Model): + collection = "poll_candidate" + verbose_name = "poll candidate" + + id = fields.IntegerField(required=True, constant=True) + poll_candidate_list_id = fields.RelationField( + to={"poll_candidate_list": "poll_candidate_ids"}, required=True, constant=True + ) + user_id = fields.RelationField(to={"user": "poll_candidate_ids"}, constant=True) + weight = fields.IntegerField(required=True) + meeting_id = fields.RelationField( + to={"meeting": "poll_candidate_ids"}, required=True, constant=True + ) + + +class PollCandidateList(Model): + collection = "poll_candidate_list" + verbose_name = "poll candidate list" + + id = fields.IntegerField(required=True, constant=True) + poll_candidate_ids = fields.RelationListField( + to={"poll_candidate": "poll_candidate_list_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + is_primary=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "poll_candidate_list_ids"}, required=True, constant=True + ) + option_id = fields.RelationField( + to={"option": "content_object_id"}, + is_view_field=True, + required=True, + constant=True, + ) + + +class Projection(Model): + collection = "projection" + verbose_name = "projection" + + id = fields.IntegerField(required=True, constant=True) + options = fields.JSONField() + stable = fields.BooleanField(default=False) + weight = fields.IntegerField() + type = fields.CharField() + current_projector_id = fields.RelationField( + to={"projector": "current_projection_ids"} + ) + preview_projector_id = fields.RelationField( + to={"projector": "preview_projection_ids"} + ) + history_projector_id = fields.RelationField( + to={"projector": "history_projection_ids"} + ) + content_object_id = fields.GenericRelationField( + to={ + "projector_countdown": "projection_ids", + "projector_message": "projection_ids", + "poll": "projection_ids", + "topic": "projection_ids", + "agenda_item": "projection_ids", + "assignment": "projection_ids", + "motion_block": "projection_ids", + "list_of_speakers": "projection_ids", + "meeting_mediafile": "projection_ids", + "motion": "projection_ids", + "meeting": "projection_ids", + }, + required=True, + constant=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "all_projection_ids"}, required=True, constant=True + ) + + +class Projector(Model): + collection = "projector" + verbose_name = "projector" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + is_internal = fields.BooleanField(default=False) + scale = fields.IntegerField(default=0) + scroll = fields.IntegerField(default=0, constraints={"minimum": 0}) + width = fields.IntegerField(default=1200, constraints={"minimum": 1}) + aspect_ratio_numerator = fields.IntegerField(default=16, constraints={"minimum": 1}) + aspect_ratio_denominator = fields.IntegerField( + default=9, constraints={"minimum": 1} + ) + color = fields.ColorField(default="#000000") + background_color = fields.ColorField(default="#ffffff") + header_background_color = fields.ColorField(default="#317796") + header_font_color = fields.ColorField(default="#f5f5f5") + header_h1_color = fields.ColorField(default="#317796") + chyron_background_color = fields.ColorField(default="#317796") + chyron_background_color_2 = fields.ColorField(default="#134768") + chyron_font_color = fields.ColorField(default="#ffffff") + chyron_font_color_2 = fields.ColorField(default="#ffffff") + show_header_footer = fields.BooleanField(default=True) + show_title = fields.BooleanField(default=True) + show_logo = fields.BooleanField(default=True) + show_clock = fields.BooleanField(default=True) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + current_projection_ids = fields.RelationListField( + to={"projection": "current_projector_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + preview_projection_ids = fields.RelationListField( + to={"projection": "preview_projector_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + history_projection_ids = fields.RelationListField( + to={"projection": "history_projector_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + used_as_reference_projector_meeting_id = fields.RelationField( + to={"meeting": "reference_projector_id"}, is_view_field=True + ) + used_as_default_projector_for_agenda_item_list_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_agenda_item_list_ids"} + ) + used_as_default_projector_for_topic_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_topic_ids"} + ) + used_as_default_projector_for_list_of_speakers_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_list_of_speakers_ids"} + ) + used_as_default_projector_for_current_los_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_current_los_ids"} + ) + used_as_default_projector_for_motion_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_motion_ids"} + ) + used_as_default_projector_for_amendment_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_amendment_ids"} + ) + used_as_default_projector_for_motion_block_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_motion_block_ids"} + ) + used_as_default_projector_for_assignment_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_assignment_ids"} + ) + used_as_default_projector_for_mediafile_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_mediafile_ids"} + ) + used_as_default_projector_for_message_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_message_ids"} + ) + used_as_default_projector_for_countdown_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_countdown_ids"} + ) + used_as_default_projector_for_assignment_poll_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_assignment_poll_ids"} + ) + used_as_default_projector_for_motion_poll_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_motion_poll_ids"} + ) + used_as_default_projector_for_poll_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_poll_ids"} + ) + meeting_id = fields.RelationField( + to={"meeting": "projector_ids"}, required=True, constant=True + ) + + +class ProjectorCountdown(Model): + collection = "projector_countdown" + verbose_name = "projector countdown" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField(required=True) + description = fields.CharField(default="") + default_time = fields.IntegerField() + countdown_time = fields.FloatField(default=60) + running = fields.BooleanField(default=False) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + used_as_list_of_speakers_countdown_meeting_id = fields.RelationField( + to={"meeting": "list_of_speakers_countdown_id"}, is_view_field=True + ) + used_as_poll_countdown_meeting_id = fields.RelationField( + to={"meeting": "poll_countdown_id"}, is_view_field=True + ) + meeting_id = fields.RelationField( + to={"meeting": "projector_countdown_ids"}, required=True, constant=True + ) + + +class ProjectorMessage(Model): + collection = "projector_message" + verbose_name = "projector message" + + id = fields.IntegerField(required=True, constant=True) + message = fields.HTMLStrictField(required=True) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "projector_message_ids"}, required=True, constant=True + ) + + +class Speaker(Model): + collection = "speaker" + verbose_name = "speaker" + + id = fields.IntegerField(required=True, constant=True) + begin_time = fields.TimestampField() + end_time = fields.TimestampField() + pause_time = fields.TimestampField(read_only=True) + unpause_time = fields.TimestampField() + total_pause = fields.IntegerField() + weight = fields.IntegerField(default=10000) + speech_state = fields.CharField( + constraints={ + "enum": [ + "contribution", + "pro", + "contra", + "intervention", + "interposed_question", + ] + } + ) + answer = fields.BooleanField() + note = fields.CharField(constraints={"maxLength": 250}) + point_of_order = fields.BooleanField(constant=True) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "speaker_ids"}, required=True, constant=True + ) + structure_level_list_of_speakers_id = fields.RelationField( + to={"structure_level_list_of_speakers": "speaker_ids"} + ) + meeting_user_id = fields.RelationField(to={"meeting_user": "speaker_ids"}) + point_of_order_category_id = fields.RelationField( + to={"point_of_order_category": "speaker_ids"} + ) + meeting_id = fields.RelationField( + to={"meeting": "speaker_ids"}, required=True, constant=True + ) + + +class StructureLevel(Model): + collection = "structure_level" + verbose_name = "structure level" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + color = fields.ColorField() + default_time = fields.IntegerField(constraints={"minimum": 0}) + meeting_user_ids = fields.RelationListField( + to={"meeting_user": "structure_level_ids"}, + is_view_field=True, + write_fields=( + "nm_meeting_user_structure_level_ids_structure_level_t", + "structure_level_id", + "meeting_user_id", + [], + ), + ) + structure_level_list_of_speakers_ids = fields.RelationListField( + to={"structure_level_list_of_speakers": "structure_level_id"}, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "structure_level_ids"}, required=True, constant=True + ) + + +class StructureLevelListOfSpeakers(Model): + collection = "structure_level_list_of_speakers" + verbose_name = "structure level list of speakers" + + id = fields.IntegerField(required=True, constant=True) + structure_level_id = fields.RelationField( + to={"structure_level": "structure_level_list_of_speakers_ids"}, required=True + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "structure_level_list_of_speakers_ids"}, required=True + ) + speaker_ids = fields.RelationListField( + to={"speaker": "structure_level_list_of_speakers_id"}, is_view_field=True + ) + initial_time = fields.IntegerField( + required=True, + constraints={ + "minimum": 1, + "description": "The initial time of this structure_level for this LoS", + }, + ) + additional_time = fields.FloatField( + constraints={ + "description": "The summed added time of this structure_level for this LoS" + } + ) + remaining_time = fields.FloatField( + required=True, + constraints={ + "description": "The currently remaining time of this structure_level for this LoS" + }, + ) + current_start_time = fields.TimestampField( + constraints={ + "description": "The current start time of a speaker for this structure_level. Is only set if a currently speaking speaker exists" + } + ) + meeting_id = fields.RelationField( + to={"meeting": "structure_level_list_of_speakers_ids"}, + required=True, + constant=True, + ) + + +class Tag(Model): + collection = "tag" + verbose_name = "tag" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + tagged_ids = fields.GenericRelationListField( + to={"agenda_item": "tag_ids", "assignment": "tag_ids", "motion": "tag_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "gm_tag_tagged_ids_t", + "tag_id", + "tagged_id", + [ + "tagged_id_agenda_item_id", + "tagged_id_assignment_id", + "tagged_id_motion_id", + ], + ), + ) + meeting_id = fields.RelationField( + to={"meeting": "tag_ids"}, required=True, constant=True + ) + + +class Theme(Model): + collection = "theme" + verbose_name = "theme" + + id = fields.IntegerField(required=True, constant=True) + name = fields.CharField(required=True) + accent_100 = fields.ColorField() + accent_200 = fields.ColorField() + accent_300 = fields.ColorField() + accent_400 = fields.ColorField() + accent_50 = fields.ColorField() + accent_500 = fields.ColorField(required=True, default="#2196f3") + accent_600 = fields.ColorField() + accent_700 = fields.ColorField() + accent_800 = fields.ColorField() + accent_900 = fields.ColorField() + accent_a100 = fields.ColorField() + accent_a200 = fields.ColorField() + accent_a400 = fields.ColorField() + accent_a700 = fields.ColorField() + primary_100 = fields.ColorField() + primary_200 = fields.ColorField() + primary_300 = fields.ColorField() + primary_400 = fields.ColorField() + primary_50 = fields.ColorField() + primary_500 = fields.ColorField(required=True, default="#317796") + primary_600 = fields.ColorField() + primary_700 = fields.ColorField() + primary_800 = fields.ColorField() + primary_900 = fields.ColorField() + primary_a100 = fields.ColorField() + primary_a200 = fields.ColorField() + primary_a400 = fields.ColorField() + primary_a700 = fields.ColorField() + warn_100 = fields.ColorField() + warn_200 = fields.ColorField() + warn_300 = fields.ColorField() + warn_400 = fields.ColorField() + warn_50 = fields.ColorField() + warn_500 = fields.ColorField(required=True, default="#f06400") + warn_600 = fields.ColorField() + warn_700 = fields.ColorField() + warn_800 = fields.ColorField() + warn_900 = fields.ColorField() + warn_a100 = fields.ColorField() + warn_a200 = fields.ColorField() + warn_a400 = fields.ColorField() + warn_a700 = fields.ColorField() + headbar = fields.ColorField() + yes = fields.ColorField() + no = fields.ColorField() + abstain = fields.ColorField() + theme_for_organization_id = fields.RelationField( + to={"organization": "theme_id"}, is_view_field=True + ) + organization_id = fields.OrganizationField( + to={"organization": "theme_ids"}, required=True, constant=True, default=1 + ) + + +class Topic(Model): + collection = "topic" + verbose_name = "topic" + + id = fields.IntegerField(required=True, constant=True) + title = fields.CharField(required=True) + text = fields.HTMLPermissiveField() + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + attachment_meeting_mediafile_ids = fields.RelationListField( + to={"meeting_mediafile": "attachment_ids"}, + is_view_field=True, + write_fields=( + "gm_meeting_mediafile_attachment_ids_t", + "topic_id", + "attachment_meeting_mediafile_id", + ["attachment_meeting_mediafile_id_meeting_mediafile_id"], + ), + ) + agenda_item_id = fields.RelationField( + to={"agenda_item": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + required=True, + constant=True, + ) + list_of_speakers_id = fields.RelationField( + to={"list_of_speakers": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + required=True, + constant=True, + ) + poll_ids = fields.RelationListField( + to={"poll": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + projection_ids = fields.RelationListField( + to={"projection": "content_object_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + meeting_id = fields.RelationField( + to={"meeting": "topic_ids"}, required=True, constant=True + ) + + +class User(Model): + collection = "user" + verbose_name = "user" + + id = fields.IntegerField(required=True, constant=True) + username = fields.CharField(required=True, unique=True) + member_number = fields.CharField(unique=True) + saml_id = fields.CharField( + unique=True, + constraints={ + "minLength": 1, + "description": "unique-key from IdP for SAML login", + }, + ) + pronoun = fields.CharField(constraints={"maxLength": 32}) + title = fields.CharField() + first_name = fields.CharField() + last_name = fields.CharField() + is_active = fields.BooleanField(default=True) + is_physical_person = fields.BooleanField(default=True) + password = fields.CharField() + default_password = fields.CharField() + can_change_own_password = fields.BooleanField(default=True) + email = fields.CharField() + default_vote_weight = fields.DecimalField( + default="1.000000", constraints={"minimum": "0.000001"} + ) + last_email_sent = fields.TimestampField() + is_demo_user = fields.BooleanField() + last_login = fields.TimestampField(read_only=True) + external = fields.BooleanField() + gender_id = fields.RelationField(to={"gender": "user_ids"}) + organization_management_level = fields.CharField( + constraints={ + "description": "Hierarchical permission level for the whole organization.", + "enum": ["superadmin", "can_manage_organization", "can_manage_users"], + } + ) + is_present_in_meeting_ids = fields.RelationListField( + to={"meeting": "present_user_ids"}, + is_view_field=True, + write_fields=( + "nm_meeting_present_user_ids_user_t", + "user_id", + "meeting_id", + [], + ), + ) + committee_ids = fields.RelationListField( + to={"committee": "user_ids"}, + is_view_field=True, + read_only=True, + constraints={ + "description": "Calculated field: Returns committee_ids, where the user is manager or member in a meeting" + }, + ) + committee_management_ids = fields.RelationListField( + to={"committee": "manager_ids"}, + is_view_field=True, + write_fields=("nm_committee_manager_ids_user_t", "user_id", "committee_id", []), + ) + meeting_user_ids = fields.RelationListField( + to={"meeting_user": "user_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + poll_voted_ids = fields.RelationListField( + to={"poll": "voted_ids"}, + is_view_field=True, + write_fields=("nm_poll_voted_ids_user_t", "user_id", "poll_id", []), + ) + option_ids = fields.RelationListField( + to={"option": "content_object_id"}, is_view_field=True, is_primary=True + ) + vote_ids = fields.RelationListField( + to={"vote": "user_id"}, is_view_field=True, is_primary=True + ) + delegated_vote_ids = fields.RelationListField( + to={"vote": "delegated_user_id"}, is_view_field=True, is_primary=True + ) + poll_candidate_ids = fields.RelationListField( + to={"poll_candidate": "user_id"}, is_view_field=True, is_primary=True + ) + home_committee_id = fields.RelationField(to={"committee": "native_user_ids"}) + history_position_ids = fields.RelationListField( + to={"history_position": "user_id"}, is_view_field=True + ) + history_entry_ids = fields.RelationListField( + to={"history_entry": "model_id"}, is_view_field=True + ) + meeting_ids = fields.RelationListField( + to={"meeting": "user_ids"}, + is_view_field=True, + read_only=True, + constraints={ + "description": "Calculated. All ids from meetings calculated via meeting_user." + }, + ) + organization_id = fields.OrganizationField( + to={"organization": "user_ids"}, required=True, constant=True, default=1 + ) + + +class Vote(Model): + collection = "vote" + verbose_name = "vote" + + id = fields.IntegerField(required=True, constant=True) + weight = fields.DecimalField(required=True, constant=True) + value = fields.CharField(required=True, constant=True) + user_token = fields.CharField(required=True, constant=True) + option_id = fields.RelationField( + to={"option": "vote_ids"}, required=True, constant=True + ) + user_id = fields.RelationField(to={"user": "vote_ids"}) + delegated_user_id = fields.RelationField(to={"user": "delegated_vote_ids"}) + meeting_id = fields.RelationField( + to={"meeting": "vote_ids"}, required=True, constant=True + ) + + +model_registry = model_registry_class.get_model_registry() diff --git a/openslides_backend/migrations/migration_handler.py b/openslides_backend/migrations/migration_handler.py index c46cf1e245..c1c811104e 100644 --- a/openslides_backend/migrations/migration_handler.py +++ b/openslides_backend/migrations/migration_handler.py @@ -1,10 +1,10 @@ import re import string -from importlib import import_module from typing import Any from psycopg import Cursor, sql from psycopg.rows import DictRow +from psycopg.types.json import Jsonb from meta.dev.src.generate_sql_schema import GenerateCodeBlocks, HelperGetNames from openslides_backend.models.base import model_registry @@ -15,12 +15,7 @@ MigrationException, MigrationSetupException, ) -from ..migrations.migration_helper import ( - MODULE_PATH, - OLD_TABLES, - MigrationHelper, - MigrationState, -) +from ..migrations.migration_helper import OLD_TABLES, MigrationHelper, MigrationState from ..shared.handlers.base_handler import BaseHandler from ..shared.interfaces.env import Env from ..shared.interfaces.logging import LoggingModule @@ -28,6 +23,12 @@ class MigrationHandler(BaseHandler): + # Including pk_ as a precaution here since it's actually a case and if it ever was applied on table code. + # Would maybe have to add other constraint prefixes if their naming pattern changed. + TABLE_RE = re.compile(r"\b(?!pk_|tr_|equal_)([A-Za-z0-9_.]+)(_t)\b") + TRIGGER_BLOCK_RE = re.compile( + r"(CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\b.*?;)", re.IGNORECASE | re.DOTALL + ) def __init__( self, @@ -52,8 +53,6 @@ def copy_table(self, table_name: str) -> None: ) ) - # TODO we might need finalization tables for future migrations to have active triggers on the table. - fields = self.cursor.execute(sql.SQL(""" SELECT * FROM information_schema.columns @@ -82,8 +81,10 @@ def setup_migration_relations(self) -> None: im_tables = set() # COPY collection tables for collection, r_tables in unified_replace_tables.items(): - self.copy_table(collection + "_t") - im_tables.update(r_tables["im_tables"]) + if not r_tables["to_create"]: + self.copy_table(collection + "_t") + im_tables.update(r_tables["im_tables"]) + im_tables_without_suffix = {table[:-2] for table in im_tables} # COPY intermediate tables for table_name in im_tables: @@ -91,7 +92,9 @@ def setup_migration_relations(self) -> None: # COPY fkey constraints for table_name in im_tables | { - r_tables["table"] for r_tables in unified_replace_tables.values() + r_tables["table"] + for r_tables in unified_replace_tables.values() + if not r_tables["to_create"] }: self.cursor.execute( sql.SQL("""SELECT @@ -119,7 +122,10 @@ def setup_migration_relations(self) -> None: results = self.cursor.fetchall() for result in results: - if result["foreign_table_name"][:-2] in unified_replace_tables: + foreign_collection = result["foreign_table_name"][:-2] + # TODO: find out how to do this. + # if not unified_replace_tables[foreign_collection].get("to_delete"): + if foreign_collection in unified_replace_tables: f_table_name = HelperGetNames.get_table_name( result["foreign_table_name"], migration=True ) @@ -160,34 +166,56 @@ def setup_migration_relations(self) -> None: def replace_suffix(m: re.Match) -> str: base = m.group(1) - return base + ("_m") + if base in unified_replace_tables or base in im_tables_without_suffix: + return base + "_m" + else: + return base + "_t" + all_tables_to_be_replaced = { + HelperGetNames.get_table_name(collection) + for collection, r_tables in unified_replace_tables.items() + if not r_tables["to_create"] + } + all_tables_to_be_replaced |= im_tables + all_tables_to_be_replaced_without_suffix = ( + set(unified_replace_tables) | im_tables_without_suffix + ) # COPY views for migration reads - for collection, r_tables in unified_replace_tables.items(): - # TODO create regex specifically for the replace tables to not change what should stay as origin table. Needed for future migrations. - table_re = re.compile(r"\b([A-Za-z0-9_.]+)(_t)\b") - - self.cursor.execute( - """ - SELECT pg_get_viewdef(%s::regclass, true) AS viewdef - FROM pg_class - WHERE relname = %s AND relkind = 'v'; - """, - (collection, collection), - ) - row = self.cursor.fetchone() - if not row: - raise ValueError(f"Source view not found: {collection}") - viewdef = table_re.sub(replace_suffix, row["viewdef"]) - self.cursor.execute( - sql.SQL("CREATE VIEW {view_m} AS {viewdef};").format( - view_m=sql.Identifier(r_tables["view"]), - viewdef=sql.SQL(viewdef), + max_index = MigrationHelper.get_backend_migration_index() + max_index_replace_tables = MigrationHelper.get_replace_tables_from_database( + self.cursor, max_index + ) + for view in MigrationHelper.get_all_view_definitions(self.cursor): + view_def = view["viewdef"] + collection = view["relname"] + mig_view_name = unified_replace_tables.get( + collection, {"view": collection + "vm"} + )["view"] + if any(table in view_def for table in all_tables_to_be_replaced): + view_def = self.TABLE_RE.sub(replace_suffix, view_def) + self.cursor.execute( + sql.SQL("CREATE VIEW {view_m} AS {viewdef};").format( + view_m=sql.Identifier(mig_view_name), + viewdef=sql.SQL(view_def), + ) ) + # If created view is not created because of replace tables: + if collection not in all_tables_to_be_replaced_without_suffix: + # additional_views.append(collection) + # TODO I'm unsatisfied with writing this information to that key of replace tables. + max_index_replace_tables[collection] = { + "additional_view": collection + } + # Not using set_migration_info because we don't want to commit the connection. + self.cursor.execute( + sql.SQL(""" + UPDATE version SET replace_tables={max_index_replace_tables} + WHERE migration_index = {max_index}; + """).format( + max_index_replace_tables=Jsonb(max_index_replace_tables), + max_index=max_index, ) - # TODO rereference all models pointing to or pointed from this collection including im tables - # (not origin tables) - # shouldn't that be done during finalize? + ) # RECREATE some relevant triggers # May be error prone due to changing constraints @@ -201,6 +229,9 @@ def replace_suffix(m: re.Match) -> str: missing_handled_attributes, missing_handled_collections_meta_attributes, im_table_code, + # TODO partitioned sequences trigger need to be migrated alongside others once a migration starts to write new data rows triggering these. + # Otherwise the new sequential numbers wouldn't be generated + # _m sequences could simply be deleted during finalization or - depending on reliability - be renamed. create_trigger_partitioned_sequences_code, create_trigger_1_1_relation_not_null_code, create_trigger_1_n_relation_not_null_code, @@ -219,10 +250,7 @@ def replace_suffix(m: re.Match) -> str: + create_trigger_equal_fields_code ) # replace with the migration names before execute - replaced_blocks = [] - trigger_re = re.compile( - r"(CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\b.*?;)", re.IGNORECASE | re.DOTALL - ) + modified_blocks = [] view_re = re.compile(r"\B'([A-Za-z0-9_.]+)'\B") def add_suffix(m: re.Match) -> str: @@ -232,12 +260,17 @@ def add_suffix(m: re.Match) -> str: else: return f"'{base}'" - for match in trigger_re.finditer(sql_text): + for match in self.TRIGGER_BLOCK_RE.finditer(sql_text): block = match.group(0) - if table_re.search(block): - modified_block = table_re.sub(replace_suffix, block) - replaced_blocks.append(view_re.sub(add_suffix, modified_block)) - sql_text = "".join(replaced_blocks) + if table_match := self.TABLE_RE.search(block): + col_or_im = table_match.group(1)[:-2] + if ( + col_or_im in unified_replace_tables + or col_or_im in im_tables_without_suffix + ): + modified_block = self.TABLE_RE.sub(replace_suffix, block) + modified_blocks.append(view_re.sub(add_suffix, modified_block)) + sql_text = "".join(modified_blocks) self.cursor.execute(sql_text) def update_sequence(self, name: str, maximum: int) -> None: @@ -290,9 +323,8 @@ def execute_migrations(self) -> None: stored in MigrationHelper.migrations. """ for index, module_name in MigrationHelper.migrations.items(): - mig_class = getattr( - import_module(f"{MODULE_PATH}{module_name}"), "Migration" - ) + mig_class = MigrationHelper.get_migration_class(module_name) + self.logger.info("Executing migration: " + module_name) # checks wether the methods are available and executes them. @@ -307,7 +339,7 @@ def migrate(self) -> None: """ Starts the migration process. """ - self.logger.info("Preparing migrations ...") + self.logger.info("Checking migratability ...") state = MigrationHelper.get_migration_state(self.cursor) match state: case MigrationState.MIGRATION_REQUIRED: @@ -324,10 +356,8 @@ def migrate(self) -> None: MigrationState.MIGRATION_RUNNING, ) # Check prerequisites - for index, module_name in MigrationHelper.migrations.items(): - mig_class = getattr( - import_module(f"{MODULE_PATH}{module_name}"), "Migration" - ) + for module_name in MigrationHelper.migrations.values(): + mig_class = MigrationHelper.get_migration_class(module_name) self.logger.info("Pre check: " + module_name + " ...") if errors := mig_class.check_prerequisites(self.cursor): if minimum_required_index: @@ -342,6 +372,7 @@ def migrate(self) -> None: self.logger.info(errors) raise MigrationSetupException(errors) MigrationHelper.write_line("migration started") + self.logger.info("Preparing migrations ...") self.set_public_tables_read_only() self.setup_migration_relations() self.execute_migrations() @@ -381,6 +412,7 @@ def set_public_tables_read_only(self) -> None: """ for table in MigrationHelper.get_public_tables(self.cursor): trigger_name = f"tr_lock_{table}" + # TODO: This code does nothing. Delete it or fix it by putting a not in front of the if clause or smth. if self.cursor.execute( sql.SQL( "SELECT 1 FROM pg_trigger WHERE tgname = {trigger_name};" @@ -433,9 +465,6 @@ def finalize(self) -> None: match state: case MigrationState.FINALIZED: return - case MigrationState.MIGRATION_REQUIRED: - self.migrate() - return self.finalize() case MigrationState.FINALIZATION_REQUIRED: self.logger.info("Finalize migrations.") case _: @@ -444,12 +473,6 @@ def finalize(self) -> None: ) MigrationHelper.write_line("finalization started") - for index, module_name in MigrationHelper.migrations.items(): - mig_class = getattr( - import_module(f"{MODULE_PATH}{module_name}"), "Migration" - ) - mig_class.cleanup(self.cursor) - unified_replace_tables, relevant_mis = ( MigrationHelper.get_unified_replace_tables_from_database(self.cursor) ) @@ -458,43 +481,65 @@ def finalize(self) -> None: self.cursor, mi, MigrationState.FINALIZATION_RUNNING ) + self.logger.info("Executing cleanup functions ...") + for index, module_name in MigrationHelper.migrations.items(): + MigrationHelper.get_migration_class(module_name).cleanup(self.cursor) + + self.logger.info("Integrating data into origin tables ...") + # First delete all additional views to prevent deleting a table that is depended upon + for r_tables in unified_replace_tables.values(): + if additional_view := r_tables.get("additional_view"): + self.cursor.execute( + sql.SQL("DROP VIEW {migration_name};").format( + migration_name=sql.Identifier(additional_view), + ) + ) + im_tables = set() # Do the general replacement for collection, migration_names in unified_replace_tables.items(): + if "table" not in migration_names: + continue # Will also drop attached intermediate tables and views. self.cursor.execute( - sql.SQL("DROP TABLE {real_name} CASCADE;").format( + sql.SQL("DROP TABLE IF EXISTS {real_name} CASCADE;").format( real_name=sql.Identifier(collection + "_t") ) ) self.cursor.execute( - sql.SQL("ALTER TABLE {migration_name} RENAME TO {real_name}").format( + sql.SQL( + "ALTER TABLE IF EXISTS {migration_name} RENAME TO {real_name}" + ).format( real_name=sql.Identifier(collection + "_t"), migration_name=sql.Identifier(migration_names["table"]), ) ) self.cursor.execute( sql.SQL( - "ALTER SEQUENCE {collection}_m_id_seq RENAME TO {collection}_t_id_seq;" + "ALTER SEQUENCE IF EXISTS {collection}_m_id_seq RENAME TO {collection}_t_id_seq;" ).format(collection=sql.SQL(collection)) ) # Will be recreated for origin table below. + # TODO I dont want IF EXISTS probably need use of real_replace_tables self.cursor.execute( - sql.SQL("DROP VIEW {migration_name};").format( + sql.SQL("DROP VIEW IF EXISTS {migration_name};").format( migration_name=sql.Identifier(migration_names["view"]), ) ) im_tables.update(migration_names["im_tables"]) # RENAME intermediate tables + # TODO move this entire block before handling of main tables for table_name in im_tables: self.cursor.execute( - sql.SQL("DROP TABLE {real_name};").format( + sql.SQL("DROP TABLE IF EXISTS {real_name};").format( real_name=sql.Identifier(table_name) ) ) self.cursor.execute( - sql.SQL("ALTER TABLE {migration_name} RENAME TO {real_name}").format( + sql.SQL( + "ALTER TABLE IF EXISTS {migration_name} RENAME TO {real_name}" + ).format( real_name=sql.Identifier(table_name), migration_name=sql.Identifier( HelperGetNames.get_table_name(table_name, migration=True) @@ -502,6 +547,7 @@ def finalize(self) -> None: ) ) + self.logger.info("Reapplying schema parts ...") # RECREATE triggers ( enum_definitions, @@ -523,10 +569,48 @@ def finalize(self) -> None: create_trigger_notify_code, errors, ) = GenerateCodeBlocks.generate_the_code() + # TODO I don't like this CREATE OR REPLACE + # TODO doesn't this need to happen early on? Like before renaming? No, but the idx isn't needed for those pointing to a mig table. + # Maybe there is a way to reimplement generate_the_code behaviour to get only deleted views. + # Or use detection similar to trigger blocks + # TODO this fails for 101 bc the table assignment_category_m doesn't have an id. + self.cursor.execute((view_name_code).replace("CREATE", "CREATE OR REPLACE")) + + # Recreate fkeys and indices + im_tables_without_suffix = {table[:-2] for table in im_tables} + relevant_blocks = [] + fkey_re = re.compile(r"(ALTER TABLE\s\b.*?;)", re.IGNORECASE | re.DOTALL) + fkey_index_re = re.compile( + r"(ALTER TABLE\s\b.*?\);)", re.IGNORECASE | re.DOTALL + ) + real_replace_tables = { + collection + for collection, r_tables in unified_replace_tables.items() + if (r_tables.get("table")) + } + for match in fkey_index_re.finditer(alter_table_code): + block = match.group(0) + if table_match := self.TABLE_RE.search(block): + col_or_im = table_match.group(1) + if ( + col_or_im in real_replace_tables + or col_or_im in im_tables_without_suffix + ): + relevant_blocks.append(block) + elif ( + col_or_im in unified_replace_tables + and (table_match := self.TABLE_RE.search(block, table_match.end())) + and table_match.group(1) in real_replace_tables + and (fkey_match := fkey_re.search(block)) + ): + # `col_or_im` is an origin table that does not get replaced (not in + # real_replace_tables) but loses its foreign keys to `table_match` + relevant_blocks.append(fkey_match.group(1)) + sql_text = "".join(relevant_blocks) + self.cursor.execute(sql_text) + sql_text = ( - view_name_code - + alter_table_code - + create_trigger_partitioned_sequences_code + create_trigger_partitioned_sequences_code + create_trigger_1_1_relation_not_null_code + create_trigger_1_n_relation_not_null_code + create_trigger_n_m_relation_not_null_code @@ -535,6 +619,19 @@ def finalize(self) -> None: + create_trigger_notify_code + create_trigger_equal_fields_code ) + relevant_blocks = [] + # Find all blocks in the shape of CREATE [CONSTRAINT] TRIGGER [..] + for match in self.TRIGGER_BLOCK_RE.finditer(sql_text): + block = match.group(0) + if table_match := self.TABLE_RE.search(block): + col_or_im = table_match.group(1) + if ( + # TODO doesn't this also need to be real_replace_tables? + col_or_im in unified_replace_tables + or col_or_im in im_tables_without_suffix + ): + relevant_blocks.append(block) + sql_text = "".join(relevant_blocks) for collection_or_imt in im_tables | set(unified_replace_tables): to_drop_triggers = self.cursor.execute( sql.SQL("""SELECT diff --git a/openslides_backend/migrations/migration_helper.py b/openslides_backend/migrations/migration_helper.py index 2af84d5dff..b2e430ac97 100644 --- a/openslides_backend/migrations/migration_helper.py +++ b/openslides_backend/migrations/migration_helper.py @@ -1,7 +1,7 @@ +import os from enum import StrEnum from importlib import import_module from io import StringIO -from os import listdir from re import Match, match from threading import Thread from typing import Any @@ -10,13 +10,15 @@ from psycopg.rows import DictRow from psycopg.types.json import Jsonb +from meta.dev.src.helper_get_names import HelperGetNames from openslides_backend.migrations.exceptions import MigrationException -from openslides_backend.models.base import model_registry +from openslides_backend.models.fields import BaseRelationField from openslides_backend.services.postgresql.db_connection_handling import ( get_new_os_conn, ) from ..shared.exceptions import ActionException +from .migration_models import MigrationModelCreateUpdate OLD_TABLES = ( "models", @@ -114,7 +116,8 @@ def load_migrations() -> None: migration_number: int reMatch: Match[str] | None - migrations = listdir(MIGRATIONS_PATH) + MigrationHelper.migrations = {} + migrations = os.listdir(MIGRATIONS_PATH) for migration in migrations: reMatch = match(r"(?P\d{4}_.*)\.py", migration) @@ -222,7 +225,7 @@ def assert_migration_index(curs: Cursor[DictRow]) -> None: if backend_migration_index > database_migration_index: raise ActionException( - f"Missing {backend_migration_index-database_migration_index} migrations to apply." + f"Missing {backend_migration_index-database_migration_index} migrations to be applied." ) if backend_migration_index < database_migration_index: @@ -235,7 +238,7 @@ def set_database_migration_info( curs: Cursor[DictRow], migration_index: int, state: str, - replace_tables: dict[str, dict[str, str | list[str]]] | None = None, + replace_tables: dict[str, dict[str, bool | str | list[str]]] | None = None, ) -> None: """ Overwrites the databases migration info in the version table at the given migration index and commits the transaction. @@ -256,7 +259,7 @@ def set_database_migration_info( "ON CONFLICT (migration_index) DO UPDATE SET {updates}" ).format( fields=sql.SQL(", ").join(sql.Identifier(k) for k in params), - values=sql.SQL(", ").join(sql.Placeholder() for _ in range(len(params))), + values=sql.SQL(", ").join(sql.Placeholder() for _ in params), updates=sql.SQL(", ").join(updates), ) curs.execute(statement, tuple(params.values())) @@ -343,26 +346,61 @@ def get_public_tables(curs: Cursor[DictRow]) -> list[str]: not in ("version", "os_notify_log_t", "truncate_tables") ] + @staticmethod + def get_migration_class(module_name: str) -> Any: + """ + Returns the class Migration within the specified module. + """ + return getattr(import_module(f"{MODULE_PATH}{module_name}"), "Migration") + @staticmethod def get_replace_tables( migration_number: int, - ) -> dict[str, dict[str, str | list[str]]]: + ) -> dict[str, dict[str, bool | str | list[str]]]: """ Returns the replace tables mapping origin table to its migration copy. """ module_name = MigrationHelper.migrations[migration_number] - migration_module = import_module(f"{MODULE_PATH}{module_name}") + migration_class = MigrationHelper.get_migration_class(module_name) + # TODO: I think this should use the PREVIOUS_MODEL_REGISTRY? + # It is used in the manager __init__, it is also used in the stats though + # model_registry = ( + # migration_class.PREVIOUS_MODEL_REGISTRY + # if use_previous_models + # else migration_class.RESULTING_MODEL_REGISTRY + # ) + # TODO Problem we can't rely on the ORIGIN_TABLES as we also rely on intermediate table information. + # That information would ultimately have to come from the yml files and that get's changed with every migration. + classes_to_create = set(migration_class.RESULTING_MODEL_REGISTRY).difference( + migration_class.PREVIOUS_MODEL_REGISTRY + ) return { col: { + "to_delete": col in migration_class.PREVIOUS_MODEL_REGISTRY + and col not in migration_class.RESULTING_MODEL_REGISTRY, + "to_create": col in classes_to_create, "table": col + "_m", "view": col + "vm", "im_tables": [ field.write_fields[0] - for field in model_registry[col]().get_relation_fields() + for field in ( + migration_class.RESULTING_MODEL_REGISTRY.get(col) + or migration_class.PREVIOUS_MODEL_REGISTRY[col] + )().get_relation_fields() if field.write_fields + and ( + not isinstance(field, BaseRelationField) + or col not in migration_class.RESULTING_MODEL_REGISTRY + or ( + any( + coll in migration_class.RESULTING_MODEL_REGISTRY + for coll in field.to + ) + ) + ) ], } - for col in migration_module.ORIGIN_COLLECTIONS + for col in classes_to_create | set(migration_class.ORIGIN_COLLECTIONS) } @staticmethod @@ -404,3 +442,105 @@ def get_unified_replace_tables_from_database( }, relevant_mis, ) + + @staticmethod + def get_view_definition(curs: Cursor[DictRow], collection: str) -> str: + curs.execute( + """ + SELECT pg_get_viewdef(%s::regclass, true) AS viewdef + FROM pg_class + WHERE relname = %s AND relkind = 'v'; + """, + (collection, collection), + ) + row = curs.fetchone() + if not row: + raise ValueError(f"Source view not found: {collection}") + return row["viewdef"] + + @staticmethod + def get_all_view_definitions(curs: Cursor[DictRow]) -> list[dict[str, Any]]: + curs.execute(""" + SELECT relname, pg_get_viewdef(relname::regclass, true) AS viewdef + FROM pg_class + WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') + AND relkind = 'v'; + """) + result = curs.fetchall() + if not result: + raise ValueError("No views found.") + return result + + # # # actual migration functions # # # + + @staticmethod + def delete_field(curs: Cursor[DictRow], collection: str, field_name: str) -> None: + viewdef = MigrationHelper.get_view_definition(curs, collection) + + curs.execute( + sql.SQL("CREATE OR REPLACE VIEW {} AS {};").format( + sql.Identifier(collection + "vm"), + sql.SQL(viewdef), + ) + ) + curs.execute( + sql.SQL("ALTER TABLE {} DROP COLUMN {};").format( + sql.Identifier( + HelperGetNames.get_table_name(collection, migration=True) + ), + sql.Identifier(field_name), + ) + ) + + @staticmethod + def delete_collection(curs: Cursor[DictRow], collection: str) -> None: + curs.execute( + sql.SQL("DROP VIEW IF EXISTS {};").format( + sql.Identifier(collection + "vm"), + ) + ) + curs.execute( + sql.SQL("DROP TABLE IF EXISTS {} CASCADE;").format( + sql.Identifier( + HelperGetNames.get_table_name(collection, migration=True) + ), + ) + ) + + @staticmethod + def rename_field( + curs: Cursor[DictRow], collection: str, old_field_name: str, new_field_name: str + ) -> None: + curs.execute( + sql.SQL("ALTER TABLE {} RENAME {} TO {};").format( + sql.Identifier( + HelperGetNames.get_table_name(collection, migration=True) + ), + sql.Identifier(old_field_name), + sql.Identifier(new_field_name), + ) + ) + + @staticmethod + def create_collection( + curs: Cursor[DictRow], + collection: str, + migrationModelClass: type[MigrationModelCreateUpdate], + ) -> None: + # TODO: implement fields + fields = migrationModelClass().get_fields_dict() + curs.execute( + sql.SQL("CREATE TABLE {} ();").format( + sql.Identifier( + HelperGetNames.get_table_name(collection, migration=True) + ), + ) + ) + curs.execute( + sql.SQL("CREATE VIEW {} AS SELECT * FROM {};").format( + sql.Identifier(collection + "vm"), + sql.Identifier( + HelperGetNames.get_table_name(collection, migration=True) + ), + ) + ) diff --git a/openslides_backend/migrations/migration_manager.py b/openslides_backend/migrations/migration_manager.py index 68710c057c..cf888db23a 100644 --- a/openslides_backend/migrations/migration_manager.py +++ b/openslides_backend/migrations/migration_manager.py @@ -3,8 +3,7 @@ from threading import Thread from typing import Any, cast -from psycopg import Cursor, sql -from psycopg.rows import DictRow +from psycopg import sql from openslides_backend.migrations.exceptions import ( MigrationException, @@ -94,35 +93,38 @@ def get_stats(self) -> dict[str, Any]: Doesn't respect initial migration if executed on a higher target migration index. """ - def count(table: str, curs: Cursor[DictRow]) -> int: + current_migration_index = MigrationHelper.get_database_migration_index( + self.cursor + ) + + if current_migration_index >= 100: + tables = MigrationHelper.get_public_tables(self.cursor) + else: + tables = [] + + def count(table: str) -> int: if MIN_NON_REL_MIGRATION <= current_migration_index < 100: - if table.endswith("_m"): - # initial migration uses the original table_t instead of migration table_m - # to count migrated models. - statement_part = sql.SQL("{table}").format( - table=sql.Identifier(table[:-2] + "_t") - ) - else: - # initial migration uses the models instead of table_t to count models - statement_part = sql.SQL( - "models WHERE fqid LIKE '{collection}/%' and deleted = false" - ).format(collection=sql.SQL(table[:-2])) + # initial migration uses the models instead of table_t to count models + statement_part = sql.SQL( + "models WHERE fqid LIKE '{collection}/%' and deleted = false" + ).format(collection=sql.SQL(table[:-2])) else: + if table not in tables: + return 0 statement_part = sql.SQL("{table}").format(table=sql.Identifier(table)) response = self.cursor.execute( sql.SQL("SELECT COUNT(*) FROM ") + statement_part ).fetchone() return (response or {}).get("count", 0) - current_migration_index = MigrationHelper.get_database_migration_index( - self.cursor - ) - if not MigrationHelper.migrate_thread_exception: migration_indices = MigrationHelper.get_indices_from_database(self.cursor) state_per_mi = MigrationHelper.get_database_migration_states( self.cursor, migration_indices ) + # TODO: Does that work with consecutive migrations or will it overwrite + # older replace tables with newer ones. + # If it does: is that good or bad? unmigrated_collections = { collection: cast(str, r_tables["table"]) for mi in migration_indices @@ -135,16 +137,20 @@ def count(table: str, curs: Cursor[DictRow]) -> int: for collection, r_tables in MigrationHelper.get_replace_tables( mi ).items() + if not r_tables["to_create"] } + # TODO: This would include collections removed in previous migrations and added collections + # for whom the migrations have not been finalized yet. + # Those will likely break. + # Maybe remove those from the unmigrated_collections stats = { - collection: { - "count": amount, - "migrated": count(migration_table, self.cursor), - } + collection: amount for collection, migration_table in unmigrated_collections.items() - if (amount := count(collection + "_t", self.cursor)) + if (amount := count(collection + "_t")) } + if exc := MigrationHelper.migrate_thread_exception: + module_name = getattr(exc, "__module__", "") return { # stats returns all output without disturbing concurrent progress calls # -> all=True @@ -152,13 +158,15 @@ def count(table: str, curs: Cursor[DictRow]) -> int: "current_migration_index": current_migration_index, "target_migration_index": self.target_migration_index, **( - {"exception": str(MigrationHelper.migrate_thread_exception)} - if MigrationHelper.migrate_thread_exception + { + "exception": f"{module_name}{'.' if module_name else ''}{type(exc).__qualname__}: {exc}" + } + if exc else {"migratable_models": stats} ), } - def assert_valid_migration_index(self, curs: Cursor[DictRow]) -> None: + def assert_valid_migration_index(self) -> None: """assert consistent migration index""" database_m_idx = MigrationHelper.get_database_migration_index(self.cursor) if database_m_idx > self.target_migration_index: @@ -178,77 +186,80 @@ def handle_request(self, payload: dict[str, Any]) -> dict[str, Any]: if not (command := payload.get("cmd")): raise View400Exception("No command provided") self.logger.info(f"Migration command: {command}") + if command not in iter(MigrationCommand): + raise View400Exception("Unknown command: " + command) with get_new_os_conn() as conn: conn.transaction() with conn.cursor() as curs: self.cursor = curs + state = MigrationHelper.get_migration_state(curs) if command == MigrationCommand.PROGRESS: return self.handle_progress_command() elif command == MigrationCommand.STATS: return {"stats": self.get_stats()} - self.assert_valid_migration_index(curs) + elif command != MigrationCommand.RESET: + self.assert_valid_migration_index() - match MigrationHelper.get_migration_state(curs): - case MigrationState.MIGRATION_RUNNING: - process = "Migration" - case MigrationState.FINALIZATION_RUNNING: - process = "Finalization" - case ( - MigrationState.MIGRATION_FAILED - | MigrationState.FINALIZATION_FAILED - ): + match state: + case MigrationState.MIGRATION_RUNNING: + process = "Migration" + case MigrationState.FINALIZATION_RUNNING: + process = "Finalization" + case ( + MigrationState.MIGRATION_FAILED + | MigrationState.FINALIZATION_FAILED + ): + raise MigrationException( + f"Migration in a failed state. Reset before trying to {command} again. Failed on: {MigrationHelper.migrate_thread_exception}" + ) + case _: + process = "" + if process: raise MigrationException( - f"Migration in a failed state. Reset before trying to {command} again. Failed on: {MigrationHelper.migrate_thread_exception}" + f"{process} is running, only 'stats' command is allowed." ) - case _: - process = "" - if process: - raise View400Exception( - f"{process} is running, only 'stats' command is allowed." - ) verbose = payload.get("verbose", False) - if command in iter(MigrationCommand): - MigrationHelper.migrate_thread_stream = StringIO() - MigrationHelper.migrate_thread_stream_read_pos = ( - MigrationHelper.migrate_thread_stream.tell() - ) - MigrationHelper.migrate_thread = thread = Thread( - target=self.execute_migrate_command, args=[command, verbose] - ) - thread.start() - thread.join(THREAD_WAIT_TIME) - if thread.is_alive(): - # Read isolation would prevent seeing the newest status otherwise. - self.cursor.connection.commit() - # Migration still running. Report current progress and return - return { - "status": MigrationHelper.get_migration_state(self.cursor), - "output": MigrationHelper.read_stream(), - } - else: - # Migration already finished/had nothing to do - return self.get_migration_result() + MigrationHelper.migrate_thread_stream = StringIO() + MigrationHelper.migrate_thread_stream_read_pos = ( + MigrationHelper.migrate_thread_stream.tell() + ) + MigrationHelper.migrate_thread = thread = Thread( + target=self.execute_migrate_command, args=[command, verbose] + ) + thread.start() + thread.join(THREAD_WAIT_TIME) + if thread.is_alive(): + # Read isolation would prevent seeing the newest status otherwise. + self.cursor.connection.commit() + # Migration still running. Report current progress and return + return { + "status": MigrationHelper.get_migration_state(self.cursor), + "output": MigrationHelper.read_stream(), + } else: - raise View400Exception("Unknown command: " + command) + # Migration already finished/had nothing to do + return self.get_migration_result() def execute_migrate_command(self, command: str, verbose: bool) -> None: """ Should be called as a new Thread. Should only be called if migration is in a correct state. Error handling in this is minimalistic. + If an exception occurs during execution for the first time, + it is stored in the MigrationHelper.migrate_thread_exception for read with MigrationCommand.STATS. """ try: with get_new_os_conn() as conn: with conn.cursor() as curs: - if ( - MigrationHelper.get_migration_state(curs) - == MigrationState.MIGRATION_RUNNING - and command != MigrationCommand.RESET - ): - raise MigrationException( - f"Cannot {command} when migration is running." - ) + state = MigrationHelper.get_migration_state(curs) + if ( + state == MigrationState.MIGRATION_REQUIRED + and command == MigrationCommand.FINALIZE + ): + self.execute_migrate_command(MigrationCommand.MIGRATE, verbose) + with get_new_os_conn() as conn: + with conn.cursor() as curs: self.handler = MigrationHandler( curs, self.env, self.services, self.logging ) @@ -257,16 +268,17 @@ def execute_migrate_command(self, command: str, verbose: bool) -> None: MigrationHelper.migrate_thread_exception = e self.logger.exception(e) except Exception as e: - MigrationHelper.migrate_thread_exception = e - self.logger.exception(e) # TODO catch this on a lower level and set it for specific faulty migration index - with get_new_os_conn() as conn: - with conn.cursor() as curs: - relevant_mis = MigrationHelper.get_unfinalized_indices(curs) - match command: - case MigrationCommand.MIGRATE: - state = MigrationState.MIGRATION_FAILED - case MigrationCommand.FINALIZE: - state = MigrationState.FINALIZATION_FAILED - for mi in relevant_mis: - MigrationHelper.set_database_migration_info(curs, mi, state) + self.logger.exception(e) + if not MigrationHelper.migrate_thread_exception: + MigrationHelper.migrate_thread_exception = e + with get_new_os_conn() as conn: + with conn.cursor() as curs: + relevant_mis = MigrationHelper.get_unfinalized_indices(curs) + match command: + case MigrationCommand.MIGRATE: + state = MigrationState.MIGRATION_FAILED + case MigrationCommand.FINALIZE: + state = MigrationState.FINALIZATION_FAILED + for mi in relevant_mis: + MigrationHelper.set_database_migration_info(curs, mi, state) diff --git a/openslides_backend/migrations/migration_models.py b/openslides_backend/migrations/migration_models.py new file mode 100644 index 0000000000..09954f1ffd --- /dev/null +++ b/openslides_backend/migrations/migration_models.py @@ -0,0 +1,168 @@ +from collections.abc import Iterable +from typing import cast + +from openslides_backend.models import fields + + +class MigrationModelMetaClass(type): + def __new__(metaclass, class_name, class_parents, class_attributes): # type: ignore + new_class = super().__new__( + metaclass, class_name, class_parents, class_attributes + ) + if getattr(new_class, "collection", None) and class_name not in [ + "CreateUpdateModel", + "DeleteModel", + "MigrationModelCreateUpdate", + "MigrationModelDelete", + "Model", + ]: + for attr_name in class_attributes: + attr = getattr(new_class, attr_name) + if isinstance(attr, fields.Field): + attr.own_collection = new_class.collection + attr.own_field_name = attr_name + new_class._migration_registry.migration_changes[new_class.collection] = ( + new_class + ) + return new_class + + +class MigrationModel: + collection: str + delete_model: bool = False + _migration_registry: "MigrationModelRegistry" + + +class MigrationModelCreateUpdate(MigrationModel, metaclass=MigrationModelMetaClass): + verbose_name: str + id = fields.IntegerField(required=True, constant=True) + + def __str__(self) -> str: + return self.verbose_name + + def get_field(self, field_name: str) -> fields.Field: + """ + Returns the requested model field. + """ + field = self.try_get_field(field_name) + if not field: + raise ValueError(f"Model {self} has no field {field_name}.") + return field + + def has_field(self, field_name: str) -> bool: + """ + Returns True if the model has such a field. + """ + return bool(self.try_get_field(field_name)) + + @classmethod + def try_get_field(cls, field_name: str) -> fields.Field | None: + """ + Returns the field for the given field name or None if field is not found. + """ + field = getattr(cls, field_name, None) + if isinstance(field, fields.Field): + return field + return None + + def get_fields(self) -> Iterable[fields.Field]: + """ + Yields all fields of this model. + """ + for attr_name in dir(self): + attr = getattr(self, attr_name) + if isinstance(attr, fields.Field): + yield attr + + def get_fields_dict(self) -> dict[str, fields.Field]: + """ + Yields all fields of this model. + """ + dic: dict[str, fields.Field] = {} + for attr_name in dir(self): + attr = getattr(self, attr_name) + if isinstance(attr, fields.Field): + dic[attr_name] = attr + return dic + + def get_relation_fields(self) -> Iterable[fields.BaseRelationField]: + """ + Yields all relation fields (using BaseRelationField). + """ + for model_field in self.get_fields(): + if isinstance(model_field, fields.BaseRelationField): + yield model_field + + def get_writable_fields(self) -> Iterable[fields.Field]: + """ + Yields all writable fields of this model. + """ + for model_field in self.get_fields(): + if not ( + isinstance(model_field, fields.RelationListField) + and model_field.is_view_field + and not model_field.write_fields + ): + yield model_field + + def get_property(self, field_name: str) -> fields.Schema: + """ + Returns JSON schema for the given field. Throws an error if it's read_only. + """ + field = self.get_field(field_name) + if field.read_only: + raise Exception( + f"The field {field_name} is read_only and cannot be used in a payload schema." + ) + return {field_name: field.get_schema()} + + def get_properties(self, *fields: str) -> dict[str, fields.Schema]: + """ + Returns a dictionary of field schemas used for the properties keyword in + an action schema. + """ + properties = {} + for field in fields: + properties.update(self.get_property(field)) + return properties + + def get_required_fields(self) -> Iterable[fields.Field]: + """ + Yields all required fields + """ + for model_field in self.get_fields(): + if model_field.required: + yield model_field + + def get_enum_array_fields(self) -> Iterable[fields.Field]: + for model_field in self.get_fields(): + if getattr(model_field, "enum_name", None) is not None: + yield model_field + + +class MigrationModelDelete(MigrationModel, metaclass=MigrationModelMetaClass): + delete_model = True + + +class MigrationModelRegistry: + def __init__( + self, + previous_model_registry: dict[str, type[MigrationModelCreateUpdate]] = {}, + ) -> None: + super().__init__() + self.previous_model_registry = previous_model_registry + self.migration_changes: dict[ + str, type[MigrationModelCreateUpdate] | type[MigrationModelDelete] + ] = {} + + def get_model_registry(self) -> dict[str, type[MigrationModelCreateUpdate]]: + model_registry = { + col: model for col, model in self.previous_model_registry.items() + } + for col, model in self.migration_changes.items(): + if model.delete_model: + if col in model_registry: + del model_registry[col] + else: + model_registry[col] = cast(type[MigrationModelCreateUpdate], model) + return model_registry diff --git a/openslides_backend/migrations/migrations/0100_init_reldb.py b/openslides_backend/migrations/migrations/0100_init_reldb.py index cdba6eda5c..4bd9cc6e7f 100644 --- a/openslides_backend/migrations/migrations/0100_init_reldb.py +++ b/openslides_backend/migrations/migrations/0100_init_reldb.py @@ -9,14 +9,17 @@ from psycopg import Cursor from psycopg.rows import DictRow -from meta.dev.src.helper_get_names import HelperGetNames # type: ignore # noqa +from meta.dev.src.helper_get_names import HelperGetNames +from openslides_backend.migrations.data.mig_0100_resulting_models import model_registry from openslides_backend.migrations.migration_helper import ( OLD_TABLES, MigrationHelper, MigrationState, ) +from openslides_backend.migrations.migration_models import ( + MigrationModelCreateUpdate as Model, +) from openslides_backend.migrations.migrations.base import BaseMigration -from openslides_backend.models.base import Model, model_registry from openslides_backend.models.fields import ( DecimalField, Field, @@ -30,58 +33,6 @@ from openslides_backend.shared.env import is_truthy RELATION_LIST_FIELD_CLASSES = [RelationListField, GenericRelationListField] -# TODO update before merging into main. -ORIGIN_COLLECTIONS = [ - "organization", - "user", - "meeting_user", - "gender", - "organization_tag", - "theme", - "committee", - "meeting", - "structure_level", - "group", - "personal_note", - "tag", - "agenda_item", - "list_of_speakers", - "structure_level_list_of_speakers", - "point_of_order_category", - "speaker", - "topic", - "motion", - "motion_submitter", - "motion_supporter", - "motion_editor", - "motion_working_group_speaker", - "motion_comment", - "motion_comment_section", - "motion_category", - "motion_block", - "motion_change_recommendation", - "motion_state", - "motion_workflow", - "poll", - "option", - "vote", - "assignment", - "assignment_candidate", - "poll_candidate_list", - "poll_candidate", - "mediafile", - "meeting_mediafile", - "projector", - "projection", - "projector_message", - "projector_countdown", - "chat_group", - "chat_message", - "action_worker", - "import_preview", - "history_position", - "history_entry", -] class Sql_helper: @@ -234,6 +185,60 @@ def get_insert_intermediate_t_commands( class Migration(BaseMigration): + ORIGIN_COLLECTIONS = [ + "organization", + "user", + "meeting_user", + "gender", + "organization_tag", + "theme", + "committee", + "meeting", + "structure_level", + "group", + "personal_note", + "tag", + "agenda_item", + "list_of_speakers", + "structure_level_list_of_speakers", + "point_of_order_category", + "speaker", + "topic", + "motion", + "motion_submitter", + "motion_supporter", + "motion_editor", + "motion_working_group_speaker", + "motion_comment", + "motion_comment_section", + "motion_category", + "motion_block", + "motion_change_recommendation", + "motion_state", + "motion_workflow", + "poll", + "option", + "vote", + "assignment", + "assignment_candidate", + "poll_candidate_list", + "poll_candidate", + "mediafile", + "meeting_mediafile", + "projector", + "projection", + "projector_message", + "projector_countdown", + "chat_group", + "chat_message", + "action_worker", + "import_preview", + "history_position", + "history_entry", + ] + RESULTING_MODEL_REGISTRY = model_registry + PREVIOUS_MODEL_REGISTRY = model_registry + @staticmethod def check_prerequisites(curs: Cursor[DictRow]) -> str: errors = "" @@ -397,7 +402,6 @@ def cleanup(curs: Cursor[DictRow]) -> None: i_read_code = None if i_read_code is not None: if is_truthy(i_read_code): - print("(┛◉Д◉)┛彡┻━┻") MigrationHelper.write_line("(┛◉Д◉)┛彡┻━┻") for table_name in OLD_TABLES: diff --git a/openslides_backend/migrations/migrations/0101_test_migration.py b/openslides_backend/migrations/migrations/0101_test_migration.py new file mode 100644 index 0000000000..40dcb287a3 --- /dev/null +++ b/openslides_backend/migrations/migrations/0101_test_migration.py @@ -0,0 +1,135 @@ +from psycopg import Cursor +from psycopg.rows import DictRow + +from openslides_backend.migrations.data.mig_0100_resulting_models import ( + Assignment as PrevAssignment, +) +from openslides_backend.migrations.data.mig_0100_resulting_models import ( + Meeting as PrevMeeting, +) +from openslides_backend.migrations.data.mig_0100_resulting_models import ( + MeetingUser as PrevMeetingUser, +) +from openslides_backend.migrations.data.mig_0100_resulting_models import ( + model_registry as prev_model_registry, +) +from openslides_backend.migrations.migration_helper import MigrationHelper +from openslides_backend.migrations.migration_models import ( + MigrationModelCreateUpdate, + MigrationModelDelete, + MigrationModelRegistry, +) +from openslides_backend.migrations.migrations.base import BaseMigration +from openslides_backend.models import fields + +registry_class_instance = MigrationModelRegistry(prev_model_registry) + + +class CreateModel(MigrationModelCreateUpdate): + _migration_registry = registry_class_instance + + +class DeleteModel(MigrationModelDelete): + _migration_registry = registry_class_instance + + +# newly created +class AssignmentCategory(CreateModel): + collection = "assignment_category" + verbose_name = "assignment category" + + name = fields.CharField(required=True) + prefix = fields.CharField() + weight = fields.IntegerField(default=10000) + level = fields.IntegerField( + read_only=True, constraints={"description": "Calculated field."} + ) + sequential_number = fields.IntegerField( + required=True, + read_only=True, + constant=True, + constraints={ + "sequence_scope": "meeting_id", + "description": "The (positive) serial number of this model in its meeting. This number is auto-generated and read-only.", + }, + ) + parent_id = fields.RelationField(to={"assignment_category": "child_ids"}) + child_ids = fields.RelationListField( + to={"assignment_category": "parent_id"}, is_view_field=True + ) + assignment_ids = fields.RelationListField( + to={"assignment": "category_id"}, is_view_field=True + ) + meeting_id = fields.RelationField( + to={"meeting": "assignment_category_ids"}, required=True, constant=True + ) + + +# updated +class Meeting(PrevMeeting): + assignment_category_ids = fields.RelationListField( + to={"assignment_category": "meeting_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, + ) + + # Rename motions_number_type to motions_assignments_number_type + motions_assignments_number_type = fields.CharField( + default="per_category", + constraints={"enum": ["per_category", "serially_numbered", "manually"]}, + ) + motions_number_type = None # type: ignore + # relations to deleted models + assignment_candidate_ids = None # type: ignore + + +class Assignment(PrevAssignment): + category_id = fields.RelationField(to={"assignment_category": "motion_ids"}) + # relations to deleted models + candidate_ids = None # type: ignore + + +class MeetingUser(PrevMeetingUser): + # relations to deleted models + assignment_candidate_ids = None # type: ignore + + +# deleted +class AssignmentCandidate(DeleteModel): + collection = "assignment_candidate" + + +model_registry = registry_class_instance.get_model_registry() + + +class Migration(BaseMigration): + ORIGIN_COLLECTIONS = [ + "meeting", + "assignment", + "meeting_user", + "assignment_candidate", + ] + RESULTING_MODEL_REGISTRY = model_registry + PREVIOUS_MODEL_REGISTRY = prev_model_registry + + @staticmethod + def data_definition(curs: Cursor[DictRow]) -> None: + MigrationHelper.rename_field( + curs, "meeting", "motions_number_type", "motions_assignments_number_type" + ) + MigrationHelper.delete_collection(curs, "assignment_candidate") + MigrationHelper.create_collection( + curs, "assignment_category", AssignmentCategory + ) + # relations to deleted models + # MigrationHelper.delete_field(curs, "meeting", "assignment_candidate_ids") + # MigrationHelper.delete_field(curs, "assignment", "candidate_ids") + # MigrationHelper.delete_field(curs, "meeting_user", "assignment_candidate_ids") + + @staticmethod + def data_manipulation(curs: Cursor[DictRow]) -> None: + pass + + @staticmethod + def cleanup(curs: Cursor[DictRow]) -> None: + pass diff --git a/openslides_backend/migrations/migrations/base.py b/openslides_backend/migrations/migrations/base.py index 7c160b6feb..9eb6f7504f 100644 --- a/openslides_backend/migrations/migrations/base.py +++ b/openslides_backend/migrations/migrations/base.py @@ -1,10 +1,18 @@ from psycopg import Cursor from psycopg.rows import DictRow +from openslides_backend.migrations.migration_models import MigrationModelCreateUpdate +from openslides_backend.shared.typing import Collection + class BaseMigration: """Interface class for all migrations""" + # Needs to be set by subclass to define the collections that need to be copied into migration tables. + ORIGIN_COLLECTIONS: list[Collection] + RESULTING_MODEL_REGISTRY: dict[str, type[MigrationModelCreateUpdate]] + PREVIOUS_MODEL_REGISTRY: dict[str, type[MigrationModelCreateUpdate]] + @staticmethod def check_prerequisites(curs: Cursor[DictRow]) -> str: """ diff --git a/setup.cfg b/setup.cfg index 1541e42be2..11ecd88146 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,6 @@ per-file-ignores = __init__.py:F401 [mypy] disallow_untyped_defs = true -exclude = tests/system/migrations namespace_packages = true [sqlfluff] diff --git a/tests/system/base.py b/tests/system/base.py index 68a61baeb4..bcc35cbfc3 100644 --- a/tests/system/base.py +++ b/tests/system/base.py @@ -2,6 +2,7 @@ from collections import defaultdict from collections.abc import Callable from copy import deepcopy +from json import loads as json_loads from typing import Any, cast from unittest import TestCase, TestResult from unittest.mock import MagicMock @@ -17,6 +18,7 @@ json_dict_to_non_json_data_types, model_registry, ) +from openslides_backend.models.fields import JSONField from openslides_backend.models.models import Meeting from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permission @@ -318,9 +320,10 @@ def validate_fields(self, fqid: str, fields: dict[str, Any]) -> None: model = model_registry[collection_from_fqid(fqid)]() for field_name, value in fields.items(): try: - model.get_field(field_name).validate_with_schema( - fqid, field_name, value - ) + field = model.get_field(field_name) + if isinstance(field, JSONField) and isinstance(value, str): + value = json_loads(value) + field.validate_with_schema(fqid, field_name, value) except ActionException as e: raise JsonSchemaException(e.message) diff --git a/tests/system/migrations/base_migration_test.py b/tests/system/migrations/base_migration_test.py index 9e0181e5d8..9842597374 100644 --- a/tests/system/migrations/base_migration_test.py +++ b/tests/system/migrations/base_migration_test.py @@ -1,10 +1,48 @@ -from unittest import TestCase +from datetime import datetime +from decimal import Decimal +from json import dumps as json_dumps +from typing import Any +from unittest.mock import patch +from zoneinfo import ZoneInfo + +from psycopg import Cursor +from psycopg.rows import DictRow from openslides_backend.migrations.migration_handler import MigrationHandler from openslides_backend.migrations.migration_helper import MigrationHelper +from openslides_backend.models.fields import ( + DecimalField, + Field, + JSONField, + TimestampField, +) +from tests.system.base import BaseSystemTestCase + + +class BaseMigrationTestCase(BaseSystemTestCase): + # has to be set by subclass + migration_file: str + patcher: Any + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + # mock the return value + cls.patcher = patch("os.listdir", return_value=[cls.migration_file]) + # start the patch + cls.patcher.start() + # stop after all tests + cls.addClassCleanup(cls.patcher.stop) + + def setUp(self) -> None: + """ + Does not call super class to prevent usage of client and so forth. + """ + self.used_collections: set[str] = set() + self.created_fqids: set[str] = set() + self.deleted_fqids: set[str] = set() -class BaseMigrationTestCase(TestCase): def tearDown(self) -> None: MigrationHelper.migrate_thread = None MigrationHelper.migrate_thread_exception = None @@ -13,6 +51,26 @@ def tearDown(self) -> None: MigrationHandler.close_migrate_thread_stream() super().tearDown() + def assert_content_not_none( + self, + cur: Cursor[DictRow], + query: str, + value: dict[str, Any] | None = None, + error_message: str = "", + ) -> None: + """ + Checks whether the first element of the result for `query` matches `value`. + `value` should be None if the expected result is just not None. + Because of this behavior, it can't be compared to an expected result of None. + """ + result = cur.execute(query).fetchone() + if error_message: + assert result, error_message + else: + assert result, f"Database did not contain a result for this query.\n{query}" + if value is not None: + assert result == value + def wait_for_migration_thread(self, for_seconds: int) -> None: """ Waits for the thread to terminate and asserts the thread is not alive. @@ -20,3 +78,26 @@ def wait_for_migration_thread(self, for_seconds: int) -> None: assert MigrationHelper.migrate_thread MigrationHelper.migrate_thread.join(for_seconds) assert not MigrationHelper.migrate_thread.is_alive() + + @staticmethod + def transform_data(data: Any, field: Field | None = None) -> Any: + """ + Purpose: + Casts data so it is psycopg friendly in case it is not parsable by psycopg. + Known and implemented cases: + - Decimals + - json (mostly Dictionaries) + - Timestamps + Input: + - data: python formatted data + Returns: + - data: psycopg friendly data + """ + if isinstance(field, DecimalField): + data = Decimal(data) + elif isinstance(field, TimestampField): + data = datetime.fromtimestamp(data, ZoneInfo("UTC")) + elif isinstance(field, JSONField): + data = json_dumps(data) + + return data diff --git a/tests/system/migrations/test_0100_init_reldb.py b/tests/system/migrations/test_0100_init_reldb.py index 981a7aa4fa..28a633fa63 100644 --- a/tests/system/migrations/test_0100_init_reldb.py +++ b/tests/system/migrations/test_0100_init_reldb.py @@ -60,8 +60,8 @@ ADMIN_USERNAME = "admin" ADMIN_PASSWORD = "admin" MIGRATIONS_URL = get_route_path(ActionView.migrations_route) -created_fqids: set() -data: dict[str, any] = {} +# created_fqids: set() +data: dict[str, Any] = {} class TestMigration100(BaseMigrationTestCase): @@ -77,6 +77,7 @@ class TestMigration100(BaseMigrationTestCase): once the actual connection context is entered the first time after the database was dropped and recreated. """ + migration_file = "0100_init_reldb.py" app: OpenSlidesBackendWSGIApplication auth: AuthenticationService # Save auth data as class variable @@ -132,7 +133,7 @@ def tearDown(self) -> None: curs.execute(f"SELECT setval('{collection}_t_id_seq', 1, false)") super().tearDown() - def setUp(self): + def setUp(self) -> None: # 0) Set up environment os.environ["MIG0100_TIMEZONE"] = "Europe/Berlin" os.environ["MIG0100_I_READ_DOCS"] = "YES" @@ -147,7 +148,7 @@ def setUp(self): # 1.1) Create services and login. self.app = create_action_test_application() self.client = Client(self.app) - self.used_collections = set() + super().setUp() self.setup_data() self.apply_test_relational_schema() @@ -168,12 +169,11 @@ def request( return response def setup_data(self) -> None: - raw_data: dict[str, any] json_blob: str # 2) reading json data from file with open(EXAMPLE_DATA_PATH) as file: - raw_data = json.loads(file.read()) + raw_data: dict[str, Any] = json.loads(file.read()) # 2.1) fill data dictionary without meta_ fields and _migration_index for collection, models in raw_data.items(): @@ -185,9 +185,6 @@ def setup_data(self) -> None: f: v for f, v in model.items() if not f.startswith("meta_") } - # 3) Open os_connection_pool - os_conn_pool.open() - # 4) Write models into db table models with os_conn_pool.connection() as conn: with conn.cursor() as curs: @@ -208,36 +205,20 @@ def setup_data(self) -> None: ) def check_data(self) -> None: - def assert_content_not_none( - query: str, value: dict[str:Any] | None = None, error_message: str = "" - ) -> None: - """ - Checks whether the first element of the result for `query` matches `value`. - `value` should be None if the expected result is just not None. - Because of this behavior, it can't be compared to an expected result of None. - """ - result = cur.execute(query).fetchone() - if error_message: - assert result, error_message - else: - assert ( - result - ), f"Database did not contain a result for this query.\n{query}" - if value is not None: - assert result == value - # 6) TEST CASES with os_conn_pool.connection() as conn: with conn.cursor() as cur: # 6.1) 1:1 relation - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT theme_id FROM organization_t WHERE id=1;", None, "1:1 relation in organization not filled.", ) # 6.1.1) 1G:1 relation - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT type, content_object_id, content_object_id_motion_id, content_object_id_topic_id FROM agenda_item_t WHERE id=1;", { "type": "common", @@ -248,12 +229,13 @@ def assert_content_not_none( ) # 6.2) 1:n relation - assert_content_not_none( - "SELECT gender_id FROM user_t WHERE id=1;", {"gender_id": 1} + self.assert_content_not_none( + cur, "SELECT gender_id FROM user_t WHERE id=1;", {"gender_id": 1} ) # 6.2.1) 1G:n relation - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT title, content_object_id, content_object_id_motion_id, content_object_id_topic_id FROM poll_t WHERE id=1;", { "title": "1", @@ -264,26 +246,31 @@ def assert_content_not_none( ) # 6.3) n:m relation - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT user_id, committee_id FROM nm_committee_manager_ids_user_t WHERE committee_id=1 ORDER BY user_id;", {"user_id": 1, "committee_id": 1}, ) # 6.3.1) nG:m relation - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT tagged_id FROM gm_organization_tag_tagged_ids_t WHERE organization_tag_id=1 AND tagged_id LIKE 'committee/1';", ) - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT tagged_id FROM gm_organization_tag_tagged_ids_t WHERE organization_tag_id=1 AND tagged_id LIKE 'meeting/1';", ) # 6.4) Set id sequences correctly - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT last_value FROM gender_t_id_seq;", {"last_value": 4}, ) # 6.4.1) Set sequential_number sequences correctly - assert_content_not_none( + self.assert_content_not_none( + cur, "SELECT last_value FROM projector_t_meeting_id1_sequential_number_seq;", {"last_value": 2}, ) @@ -296,8 +283,9 @@ def assert_content_not_none( assert cur.fetchone() is None # 6.6) Recreated constraints - assert_content_not_none( - "SELECT 1 FROM information_schema.constraint_column_usage WHERE constraint_name = 'fk_option_t_content_object_id_poll_candidate_list_id_pold428251';" + self.assert_content_not_none( + cur, + "SELECT 1 FROM information_schema.constraint_column_usage WHERE constraint_name = 'fk_option_t_content_object_id_poll_candidate_list_id_pold428251';", ) # 6.7) Recreated triggers @@ -308,12 +296,14 @@ def assert_content_not_none( "tr_generate_sequence_motion_block_sequential_number", "tr_log_tagged_id_meeting_id_gm_organization_tag_tagged_ids_t", ]: - assert_content_not_none( - f"SELECT 1 FROM pg_trigger WHERE tgname = '{trigger_name}';" + self.assert_content_not_none( + cur, + f"SELECT 1 FROM pg_trigger WHERE tgname = '{trigger_name}';", ) # 6.8 Recreated foreign key constraints - assert_content_not_none( + self.assert_content_not_none( + cur, """SELECT 1 FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu @@ -326,11 +316,11 @@ def assert_content_not_none( AND ccu.table_name = 'theme_t' AND kcu.column_name = 'theme_id' AND ccu.column_name = 'id' - AND tc.constraint_name = 'fk_organization_t_theme_id_theme_t_id';""" + AND tc.constraint_name = 'fk_organization_t_theme_id_theme_t_id';""", ) # 6.9) Recreated views - assert_content_not_none("SELECT 1 from organization;") + self.assert_content_not_none(cur, "SELECT 1 from organization;") # 6.10) Created correct timestamp assert cur.execute( @@ -367,7 +357,7 @@ def test_migration_fail_prerequisites(self) -> None: response = self.request("stats") assert response.json["stats"] == { "status": MigrationState.MIGRATION_REQUIRED, - "exception": f"Pre check for migration 0100_init_reldb failed.\n{expected_error_message}", + "exception": f"openslides_backend.migrations.exceptions.MigrationSetupException: Pre check for migration 0100_init_reldb failed.\n{expected_error_message}", "output": f"{self.EXPECTED_INTRODUCTION}{expected_error_message}\n", "current_migration_index": 73, "target_migration_index": 100, @@ -382,7 +372,7 @@ def test_migration_fail_time_zone(self) -> None: response = self.request("stats") assert response.json["stats"] == { "status": MigrationState.MIGRATION_REQUIRED, - "exception": f"Pre check for migration 0100_init_reldb failed.\n{expected_error_message}", + "exception": f"openslides_backend.migrations.exceptions.MigrationSetupException: Pre check for migration 0100_init_reldb failed.\n{expected_error_message}", "output": f"{self.EXPECTED_INTRODUCTION}{expected_error_message}\n", "current_migration_index": 73, "target_migration_index": 100, @@ -408,7 +398,12 @@ def test_migration_handler(self) -> None: def test_migration_manager(self) -> None: # 5) Call data_manipulation of module manager = MigrationManager(Mock(), Mock(), self.app.logging) - manager.handle_request({"cmd": "migrate", "verbose": True}) + result = manager.handle_request({"cmd": "migrate", "verbose": True}) + assert result == { + "status": MigrationState.MIGRATION_RUNNING, + "output": self.EXPECTED_INTRODUCTION + + "For setting organization and meeting time zones using 'Europe/Berlin'.\nmigration started\n", + } self.wait_for_migration_thread(self.MAX_WAIT) self.assert_indices_state(MigrationState.FINALIZATION_REQUIRED) @@ -454,44 +449,44 @@ def test_migration_route_0(self, method_mock: Mock) -> None: "current_migration_index": MIN_NON_REL_MIGRATION, "target_migration_index": 100, "migratable_models": { - "agenda_item": {"count": 15, "migrated": 0}, - "assignment": {"count": 2, "migrated": 0}, - "assignment_candidate": {"count": 5, "migrated": 0}, - "chat_group": {"count": 2, "migrated": 0}, - "committee": {"count": 1, "migrated": 0}, - "gender": {"count": 4, "migrated": 0}, - "group": {"count": 5, "migrated": 0}, - "list_of_speakers": {"count": 16, "migrated": 0}, - "mediafile": {"count": 1, "migrated": 0}, - "meeting": {"count": 1, "migrated": 0}, - "meeting_mediafile": {"count": 1, "migrated": 0}, - "meeting_user": {"count": 3, "migrated": 0}, - "motion": {"count": 4, "migrated": 0}, - "motion_block": {"count": 1, "migrated": 0}, - "motion_category": {"count": 2, "migrated": 0}, - "motion_change_recommendation": {"count": 2, "migrated": 0}, - "motion_comment": {"count": 1, "migrated": 0}, - "motion_comment_section": {"count": 1, "migrated": 0}, - "motion_state": {"count": 15, "migrated": 0}, - "motion_submitter": {"count": 4, "migrated": 0}, - "motion_supporter": {"count": 1, "migrated": 0}, - "motion_workflow": {"count": 2, "migrated": 0}, - "option": {"count": 13, "migrated": 0}, - "organization": {"count": 1, "migrated": 0}, - "organization_tag": {"count": 1, "migrated": 0}, - "personal_note": {"count": 1, "migrated": 0}, - "poll": {"count": 5, "migrated": 0}, - "projection": {"count": 4, "migrated": 0}, - "projector": {"count": 2, "migrated": 0}, - "projector_countdown": {"count": 2, "migrated": 0}, - "projector_message": {"count": 1, "migrated": 0}, - "speaker": {"count": 13, "migrated": 0}, - "structure_level": {"count": 3, "migrated": 0}, - "tag": {"count": 3, "migrated": 0}, - "theme": {"count": 3, "migrated": 0}, - "topic": {"count": 8, "migrated": 0}, - "user": {"count": 3, "migrated": 0}, - "vote": {"count": 9, "migrated": 0}, + "agenda_item": 15, + "assignment": 2, + "assignment_candidate": 5, + "chat_group": 2, + "committee": 1, + "gender": 4, + "group": 5, + "list_of_speakers": 16, + "mediafile": 1, + "meeting": 1, + "meeting_mediafile": 1, + "meeting_user": 3, + "motion": 4, + "motion_block": 1, + "motion_category": 2, + "motion_change_recommendation": 2, + "motion_comment": 1, + "motion_comment_section": 1, + "motion_state": 15, + "motion_submitter": 4, + "motion_supporter": 1, + "motion_workflow": 2, + "option": 13, + "organization": 1, + "organization_tag": 1, + "personal_note": 1, + "poll": 5, + "projection": 4, + "projector": 2, + "projector_countdown": 2, + "projector_message": 1, + "speaker": 13, + "structure_level": 3, + "tag": 3, + "theme": 3, + "topic": 8, + "user": 3, + "vote": 9, }, }, } diff --git a/tests/system/migrations/test_0101_test_migration.py b/tests/system/migrations/test_0101_test_migration.py new file mode 100644 index 0000000000..0d3236a47e --- /dev/null +++ b/tests/system/migrations/test_0101_test_migration.py @@ -0,0 +1,139 @@ +import json +import os +from importlib import import_module +from typing import Any +from unittest.mock import Mock + +from openslides_backend.migrations.migration_helper import ( + MigrationHelper, + MigrationState, +) +from openslides_backend.migrations.migration_manager import MigrationManager +from openslides_backend.models.base import model_registry +from openslides_backend.services.postgresql.db_connection_handling import os_conn_pool +from openslides_backend.shared.patterns import ( + FullQualifiedId, + fqid_from_collection_and_id, +) +from openslides_backend.shared.typing import Model +from tests.system.migrations.base_migration_test import BaseMigrationTestCase + +migration_module = import_module( + "openslides_backend.migrations.migrations.0101_test_migration" +) +EXAMPLE_DATA_PATH = os.path.realpath( + os.path.join( + os.getcwd(), "tests", "system", "migrations", "legacy-example-data.json" + ) +) + + +class TestMigration101(BaseMigrationTestCase): + """ + TODO: This is not an actual valid migration with intended migration activities. + It is a development thing. + A place to check if everything works as intended. + Do NOT merge it into main. + """ + + migration_file = "0101_test_migration.py" + + def setUp(self) -> None: + super().setUp() + data: dict[FullQualifiedId, Model] = dict() + + with os_conn_pool.connection() as conn: + with conn.cursor() as curs: + # 4.D1) clears models table + curs.execute("TRUNCATE TABLE organization_t CASCADE;") + curs.execute("TRUNCATE TABLE version;") + + with open(EXAMPLE_DATA_PATH) as file: + raw_data: dict[str, Any] = json.loads(file.read()) + + for collection, models in raw_data.items(): + if collection == "_migration_index": + continue + self.used_collections.add(collection) + for model_id, model in models.items(): + data[fqid_from_collection_and_id(collection, model_id)] = { + f: self.transform_data( + v, model_registry[collection]().try_get_field(f) + ) + for f, v in model.items() + if not f.startswith("meta_") + } + self.set_models(data) + + # TODO this step is probably equal for all tests # TODO should be done for all previous # TODO just use mig number for this + with os_conn_pool.connection() as conn: + with conn.cursor() as curs: + MigrationHelper.set_database_migration_info( + curs, 100, MigrationState.FINALIZED + ) + + def test_simple(self) -> None: + manager = MigrationManager(Mock(), Mock(), Mock()) + assert manager.handle_request({"cmd": "finalize", "verbose": True}) == { + "output": "migration started\n", + "status": MigrationState.MIGRATION_RUNNING, + } + + assert manager.handle_request({"cmd": "stats", "verbose": True}) == { + "stats": { + "current_migration_index": 100, + "target_migration_index": 101, + "status": MigrationState.MIGRATION_RUNNING, + "output": "migration started\n", + "migratable_models": { + "assignment": 2, + "assignment_candidate": 5, + "meeting": 1, + "meeting_user": 3, + }, + }, + } + self.wait_for_migration_thread(15) + assert manager.handle_request({"cmd": "stats", "verbose": True}) == { + "stats": { + "current_migration_index": 101, + "target_migration_index": 101, + "status": MigrationState.FINALIZED, + "output": "migration started\nmigration finished\nfinalization started\nfinalization finished\n", + "migratable_models": {}, + }, + } + with os_conn_pool.connection() as conn: + with conn.cursor() as curs: + MigrationHelper.assert_migration_index(curs) + # 6.8 Recreated foreign key constraints for tables adjacent to mig tables + self.assert_content_not_none( + curs, + """SELECT 1 + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_name = 'committee_t' + AND ccu.table_name = 'meeting_t' + AND kcu.column_name = 'default_meeting_id' + AND ccu.column_name = 'id' + AND tc.constraint_name = 'fk_committee_t_default_meeting_id_meeting_t_id';""", + ) + self.assert_content_not_none( + curs, + "SELECT 1 FROM pg_indexes where indexname='idx_committee_t_default_meeting_id';", + ) + self.assert_content_not_none( + curs, + """SELECT EXISTS ( + SELECT * FROM information_schema.tables + WHERE table_name = 'assignment_candidate_t' + ) + """, + value={"exists": False}, + ) + self.assert_model_exists("meeting/1", {"motions_number_type": None})