diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 7a4f03eaf8..fbea962e87 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -89,13 +89,6 @@ jobs: ref: ${{ env.AUTH_COMMIT_HASH }} path: openslides-auth-service - - name: Clone vote service - uses: actions/checkout@v7 - with: - repository: OpenSlides/openslides-vote-service - ref: main - path: openslides-vote-service - - name: Run local setup working-directory: openslides-backend run: dev/run-local.sh diff --git a/cli/generate_models.py b/cli/generate_models.py index cd646789ef..a6cf71ff22 100644 --- a/cli/generate_models.py +++ b/cli/generate_models.py @@ -18,6 +18,7 @@ from openslides_backend.models.mixins import ( AgendaItemModelMixin, MeetingModelMixin, + MeetingPollDefaultModelMixin, PollModelMixin, ) from openslides_backend.shared.patterns import KEYSEPARATOR, Collection @@ -59,6 +60,7 @@ MODEL_MIXINS: dict[str, type] = { "agenda_item": AgendaItemModelMixin, "meeting": MeetingModelMixin, + "meeting_poll_default": MeetingPollDefaultModelMixin, "poll": PollModelMixin, } diff --git a/data/example-data.json b/data/example-data.json index ffed7cbe54..663fec2c0b 100644 --- a/data/example-data.json +++ b/data/example-data.json @@ -22,8 +22,7 @@ "4":{ "id": 4, "name": "non-binary", - "organization_id": 1, - "user_ids":[] + "organization_id": 1 } }, "organization": { @@ -49,7 +48,6 @@ 1 ], "theme_id": 1, - "mediafile_ids": [], "theme_ids": [ 1, 2, @@ -97,10 +95,6 @@ 1 ], "committee_management_ids": [1], - "poll_voted_ids": [5], - "option_ids": [5, 7], - "vote_ids": [9], - "delegated_vote_ids": [9], "meeting_user_ids": [1], "meeting_ids": [ 1 @@ -121,7 +115,6 @@ "committee_ids": [ 1 ], - "option_ids": [9, 12], "meeting_user_ids": [2], "meeting_ids": [ 1 @@ -139,7 +132,6 @@ "can_change_own_password": true, "gender_id": 3, "default_vote_weight": "1.000000", - "option_ids": [8, 11], "meeting_user_ids": [3], "meeting_ids": [ 1 @@ -164,7 +156,10 @@ "motion_submitter_ids": [1, 2, 3, 4], "assignment_candidate_ids": [1], "structure_level_ids": [1], - "group_ids": [2] + "group_ids": [2], + "poll_option_ids": [1, 8], + "acting_ballot_ids": [1], + "represented_ballot_ids": [1] }, "2": { "id": 2, @@ -176,6 +171,7 @@ "vote_weight": "1.000000", "speaker_ids": [2, 3, 7, 10, 11, 13], "assignment_candidate_ids": [3, 5], + "poll_option_ids": [9], "structure_level_ids": [2], "group_ids": [5] }, @@ -190,6 +186,7 @@ "speaker_ids": [4, 8, 9], "motion_supporter_ids": [1], "assignment_candidate_ids": [2, 4], + "poll_option_ids": [2, 10], "structure_level_ids": [3], "group_ids": [5] } @@ -356,18 +353,8 @@ "motions_export_submitter_recommendation": true, "motions_export_follow_recommendation": false, "motions_enable_restricted_editor_for_non_manager": true, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_group_ids": [ - 2, - 3 - ], - "motion_poll_default_backend": "fast", - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, "users_enable_presence_view": true, "users_enable_vote_weight": false, "users_enable_vote_delegations": true, @@ -379,30 +366,16 @@ "users_email_subject": "OpenSlides access data", "users_email_body": "Dear {name},\n\nthis is your personal OpenSlides login:\n\n{url}\nUsername: {username}\nPassword: {password}\n\n\nThis email was generated automatically.", "assignments_export_title": "Elections", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": false, - "assignment_poll_enable_max_votes_per_option": false, - "assignment_poll_sort_poll_result_by_votes": true, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_group_ids": [ - 2, - 3 - ], - "assignment_poll_default_backend": "fast", - "poll_ballot_paper_selection": "CUSTOM_NUMBER", - "poll_ballot_paper_number": 8, - "poll_sort_poll_result_by_votes": true, - "poll_default_type": "nominal", - "poll_default_method": "votes", - "poll_default_onehundred_percent_base": "valid", - "poll_default_group_ids": [ - 3 - ], + "poll_enable_max_votes_per_option": false, + "assignment_poll_default_method": "selection", + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + "topic_poll_default_method": "selection", "poll_default_live_voting_enabled": false, - "poll_default_backend": "fast", + "poll_default_allow_invalid": false, + "poll_default_allow_vote_split": false, "poll_couple_countdown": true, "meeting_user_ids": [1, 2, 3], "projector_ids": [ @@ -548,38 +521,14 @@ 4, 5 ], + "poll_default_ids": [1, 2, 3], "poll_ids": [ - 1, - 2, - 3, - 4, - 5 - ], - "option_ids": [ 1, 2, 3, 4, 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13 - ], - "vote_ids": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9 + 6 ], "assignment_ids": [ 1, @@ -629,7 +578,7 @@ "default_projector_countdown_ids": [1], "default_projector_assignment_poll_ids": [1], "default_projector_motion_poll_ids": [1], - "default_projector_poll_ids": [1], + "default_projector_topic_poll_ids": [1], "projection_ids": [ 3 ], @@ -638,6 +587,45 @@ "meeting_mediafile_ids": [1] } }, + "meeting_poll_default": { + "1": { + "id": 1, + "meeting_id": 1, + "used_as_assignment_poll_config_in_meeting_id": 1, + "group_ids": [2, 3], + "sort_result_by_votes": true, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": false, + "allow_nota": false, + "strike_out": false + }, + "2": { + "id": 2, + "meeting_id": 1, + "used_as_motion_poll_config_in_meeting_id": 1, + "group_ids": [2, 3], + "sort_result_by_votes": true, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": true, + "allow_nota": false, + "strike_out": false + }, + "3": { + "id": 3, + "meeting_id": 1, + "used_as_topic_poll_config_in_meeting_id": 1, + "group_ids": [3], + "sort_result_by_votes": true, + "visibility": "named", + "onehundred_percent_base": "valid", + "allow_abstain": true, + "allow_nota": false, + "strike_out": false, + "display_chart": "pie" + } + }, "group": { "1": { "id": 1, @@ -666,7 +654,6 @@ "id": 2, "name": "Admin", "admin_group_for_meeting_id": 1, - "permissions": [], "weight": 2, "meeting_user_ids": [ 1 @@ -686,11 +673,10 @@ 2 ], "poll_ids": [ - 5 + 5, 6 ], - "used_as_motion_poll_default_id": 1, - "used_as_assignment_poll_default_id": 1, - "meeting_id": 1 + "meeting_id": 1, + "used_in_meeting_poll_default_ids": [1, 2] }, "3": { "id": 3, @@ -707,7 +693,10 @@ "meeting.can_see_history", "motion.can_manage", "motion.can_manage_polls", - "poll.can_manage", + "agenda_item.can_manage_polls", + "assignment.can_manage_polls", + "motion.can_manage_polls", + "poll.can_see_progress", "projector.can_manage", "tag.can_manage", "user.can_manage" @@ -725,10 +714,8 @@ "write_comment_section_ids": [ 1 ], - "used_as_motion_poll_default_id": 1, - "used_as_assignment_poll_default_id": 1, - "used_as_poll_default_id": 1, - "meeting_id": 1 + "meeting_id": 1, + "used_in_meeting_poll_default_ids": [1, 2, 3] }, "4": { "id": 4, @@ -979,7 +966,7 @@ "list_of_speakers": { "1": { "id": 1, - "content_object_id": "motion/1", + "content_object_id": "motion/1", "closed": false, "speaker_ids": [ 11, @@ -990,13 +977,13 @@ }, "2": { "id": 2, - "content_object_id": "motion/2", + "content_object_id": "motion/2", "closed": false, "meeting_id": 1 }, "3": { "id": 3, - "content_object_id": "topic/1", + "content_object_id": "topic/1", "closed": false, "speaker_ids": [ 1, @@ -1006,25 +993,25 @@ }, "4": { "id": 4, - "content_object_id": "topic/2", + "content_object_id": "topic/2", "closed": false, "meeting_id": 1 }, "5": { "id": 5, - "content_object_id": "topic/3", + "content_object_id": "topic/3", "closed": false, "meeting_id": 1 }, "6": { "id": 6, - "content_object_id": "topic/4", + "content_object_id": "topic/4", "closed": false, "meeting_id": 1 }, "7": { "id": 7, - "content_object_id": "topic/5", + "content_object_id": "topic/5", "closed": false, "speaker_ids": [ 3, @@ -1034,7 +1021,7 @@ }, "8": { "id": 8, - "content_object_id": "topic/6", + "content_object_id": "topic/6", "closed": false, "speaker_ids": [ 5 @@ -1043,19 +1030,19 @@ }, "9": { "id": 9, - "content_object_id": "topic/7", + "content_object_id": "topic/7", "closed": false, "meeting_id": 1 }, "10": { "id": 10, - "content_object_id": "topic/8", + "content_object_id": "topic/8", "closed": false, "meeting_id": 1 }, "11": { "id": 11, - "content_object_id": "assignment/1", + "content_object_id": "assignment/1", "closed": false, "speaker_ids": [ 6, @@ -1066,19 +1053,19 @@ }, "12": { "id": 12, - "content_object_id": "motion/3", + "content_object_id": "motion/3", "closed": false, "meeting_id": 1 }, "13": { "id": 13, - "content_object_id": "motion/4", + "content_object_id": "motion/4", "closed": false, "meeting_id": 1 }, "14": { "id": 14, - "content_object_id": "assignment/2", + "content_object_id": "assignment/2", "closed": false, "speaker_ids": [ 9, @@ -1088,13 +1075,13 @@ }, "15": { "id": 15, - "content_object_id": "motion_block/1", + "content_object_id": "motion_block/1", "closed": false, "meeting_id": 1 }, "16": { "id": 16, - "content_object_id": "meeting_mediafile/1", + "content_object_id": "meeting_mediafile/1", "closed": false, "meeting_id": 1 } @@ -1198,56 +1185,60 @@ "1": { "id": 1, "title": "A", - "agenda_item_id": 3, + "agenda_item_id": 3, "list_of_speakers_id": 3, - "meeting_id": 1 + "meeting_id": 1, + "poll_ids": [ + 4 + ] + }, "2": { "id": 2, "title": "B", - "agenda_item_id": 4, + "agenda_item_id": 4, "list_of_speakers_id": 4, "meeting_id": 1 }, "3": { "id": 3, "title": "C", - "agenda_item_id": 5, + "agenda_item_id": 5, "list_of_speakers_id": 5, "meeting_id": 1 }, "4": { "id": 4, "title": "D", - "agenda_item_id": 6, + "agenda_item_id": 6, "list_of_speakers_id": 6, "meeting_id": 1 }, "5": { "id": 5, "title": "E", - "agenda_item_id": 7, + "agenda_item_id": 7, "list_of_speakers_id": 7, "meeting_id": 1 }, "6": { "id": 6, "title": "F", - "agenda_item_id": 8, + "agenda_item_id": 8, "list_of_speakers_id": 8, "meeting_id": 1 }, "7": { "id": 7, "title": "G", - "agenda_item_id": 9, + "agenda_item_id": 9, "list_of_speakers_id": 9, "meeting_id": 1 }, "8": { "id": 8, "title": "H", - "agenda_item_id": 10, + "agenda_item_id": 10, "list_of_speakers_id": 10, "meeting_id": 1 } @@ -1257,7 +1248,7 @@ "id": 1, "number": "A1", "number_value": 1, - "title": "test", + "title": "test", "text": "
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi.Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem.Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus. Nullam accumsan lorem in dui. Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut
", "text_hash": "2d59f168675841ecc69e3040ae3eca7c", @@ -1275,10 +1266,6 @@ 1 ], "poll_ids": [ - 1, - 2 - ], - "option_ids": [ 1, 3 ], @@ -1293,7 +1280,7 @@ "id": 2, "number": "1 - 1", "number_value": 1, - "title": "\u00c4nderungsantrag zu 1", + "title": "\u00c4nderungsantrag zu 1", "diff_version": "0.1.2", "text": "lömk
", "text_hash": "0339e76557663e3a6f03b9e347409f97", @@ -1324,7 +1311,7 @@ "id": 3, "number": "2", "number_value": 2, - "title": "ohne", + "title": "ohne", "diff_version": "0.1.2", "text": "sf
", "text_hash": "60d31eb37595dd44584be5ef363283e3", @@ -1356,7 +1343,7 @@ "id": 4, "number": "3", "number_value": 3, - "title": "komplex", + "title": "komplex", "text": "sdf sdfpdfkw wef
\n\nwepkf
\n\nweüpfk
\n\nweüpfdfg
", "text_hash": "af905e34b34d2b08b60c6af41153f078", "category_weight": 10000, @@ -1424,7 +1411,7 @@ "id": 1, "name": "Neu", "weight": 10000, - "comment_ids": [ + "comment_ids": [ 1 ], "read_group_ids": [ @@ -1445,7 +1432,7 @@ "prefix": "C", "weight": 2, "level": 0, - "child_ids": [ + "child_ids": [ 2 ], "motion_ids": [ @@ -1459,7 +1446,7 @@ "prefix": "B", "weight": 4, "level": 1, - "parent_id": 1, + "parent_id": 1, "motion_ids": [ 1, 3 @@ -1471,7 +1458,7 @@ "1": { "id": 1, "title": "BLOCK A", - "motion_ids": [ + "motion_ids": [ 3, 4 ], @@ -1532,12 +1519,12 @@ ], "workflow_id": 1, "first_state_of_workflow_id": 1, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "set_workflow_timestamp": true, "allow_motion_forwarding": true, "allow_amendment_forwarding": false, + "restrictions": [], "meeting_id": 1 }, "2": { @@ -1552,7 +1539,6 @@ 1 ], "workflow_id": 1, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1561,6 +1547,7 @@ "set_workflow_timestamp": false, "allow_motion_forwarding": true, "allow_amendment_forwarding": true, + "restrictions": [], "meeting_id": 1 }, "3": { @@ -1575,7 +1562,6 @@ 1 ], "workflow_id": 1, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1584,6 +1570,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "4": { @@ -1598,7 +1585,6 @@ 1 ], "workflow_id": 1, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1607,6 +1593,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "5": { @@ -1623,7 +1610,6 @@ ], "workflow_id": 2, "first_state_of_workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_create_poll": false, @@ -1631,6 +1617,7 @@ "set_workflow_timestamp": true, "allow_motion_forwarding": true, "allow_amendment_forwarding": true, + "restrictions": [], "meeting_id": 1 }, "6": { @@ -1649,7 +1636,6 @@ 5 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1658,6 +1644,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "7": { @@ -1684,7 +1671,6 @@ 4 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1693,6 +1679,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "8": { @@ -1707,7 +1694,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1716,6 +1702,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "9": { @@ -1730,7 +1717,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1739,6 +1725,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "10": { @@ -1754,7 +1741,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1763,6 +1749,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "11": { @@ -1777,7 +1764,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1786,6 +1772,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "12": { @@ -1800,7 +1787,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1809,6 +1795,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "13": { @@ -1823,7 +1810,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1832,6 +1818,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "14": { @@ -1845,7 +1832,6 @@ 7 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1854,6 +1840,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 }, "15": { @@ -1868,7 +1855,6 @@ 6 ], "workflow_id": 2, - "restrictions": [], "show_state_extension_field": false, "show_recommendation_extension_field": false, "allow_submitter_edit": false, @@ -1877,6 +1863,7 @@ "allow_motion_forwarding": true, "allow_amendment_forwarding": true, "set_workflow_timestamp": false, + "restrictions": [], "meeting_id": 1 } }, @@ -1884,7 +1871,7 @@ "1": { "id": 1, "name": "Simple Workflow", - "state_ids": [ + "state_ids": [ 1, 2, 3, @@ -1898,7 +1885,7 @@ "2": { "id": 2, "name": "Complex Workflow", - "state_ids": [ + "state_ids": [ 5, 6, 7, @@ -1919,370 +1906,306 @@ "1": { "id": 1, "title": "1", - "type": "analog", - "backend": "fast", - "pollmethod": "YNA", + "config_id": "poll_config_approval/1", + "visibility": "manually", "state": "finished", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "onehundred_percent_base": "YNA", - "votesvalid": "2.000000", - "votesinvalid": "9.000000", - "votescast": "2.000000", - "content_object_id": "motion/1", - "option_ids": [ - 1 + "anonymized": true, + "live_voting_enabled": false, + "published": true, + "allow_invalid": false, + "allow_vote_split": false, + "content_object_id": "motion/1", + "ballot_ids": [ + 1, + 2, + 3 ], - "global_option_id": 2, - "global_abstain": false, - "global_no": false, - "global_yes": false, - "meeting_id": 1, - "live_voting_enabled": false + "meeting_id": 1 }, "2": { "id": 2, "title": "2", - "type": "analog", - "backend": "fast", - "pollmethod": "YNA", - "state": "created", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "onehundred_percent_base": "YNA", - "content_object_id": "motion/1", - "option_ids": [ - 3 - ], - "global_option_id": 4, - "global_abstain": false, - "global_no": false, - "global_yes": false, - "meeting_id": 1, - "live_voting_enabled": false + "config_id": "poll_config_approval/2", + "visibility": "manually", + "option_ids": [1, 2], + "state": "finished", + "anonymized": true, + "live_voting_enabled": false, + "published": true, + "allow_invalid": false, + "allow_vote_split": false, + "content_object_id": "assignment/1", + "meeting_id": 1 }, "3": { "id": 3, - "title": "1", - "type": "analog", - "backend": "fast", - "pollmethod": "YNA", - "state": "created", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "global_yes": false, - "global_no": true, - "global_abstain": true, - "onehundred_percent_base": "YNA", - "content_object_id": "assignment/1", - "option_ids": [ - 5 - ], - "global_option_id": 6, - "meeting_id": 1, - "live_voting_enabled": false + "title": "3", + "config_id": "poll_config_selection/1", + "visibility": "open", + "option_ids": [3, 4, 5], + "state": "started", + "anonymized": false, + "live_voting_enabled": false, + "published": false, + "allow_invalid": false, + "allow_vote_split": false, + "content_object_id": "motion/1", + "ballot_ids": [9], + "ballot_user_ids": [1], + "meeting_id": 1 }, "4": { "id": 4, - "title": "2", - "type": "analog", - "backend": "fast", - "pollmethod": "Y", - "state": "finished", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "global_yes": false, - "global_no": true, - "global_abstain": true, - "onehundred_percent_base": "Y", - "votesvalid": "9.000000", - "votesinvalid": "2.000000", - "votescast": "16.000000", - "content_object_id": "assignment/1", - "option_ids": [ - 7, - 8, - 9 - ], - "global_option_id": 10, - "meeting_id": 1, - "live_voting_enabled": false + "title": "4", + "config_id": "poll_config_rating_score/1", + "visibility": "secret", + "option_ids": [6, 7], + "state": "created", + "anonymized": false, + "live_voting_enabled": false, + "published": false, + "allow_invalid": false, + "allow_vote_split": false, + "content_object_id": "topic/1", + "meeting_id": 1 }, "5": { "id": 5, - "title": "Wahlgang", - "type": "named", - "backend": "fast", - "pollmethod": "Y", + "title": "5", + "config_id": "poll_config_rating_approval/1", + "visibility": "manually", + "option_ids": [8, 9, 10], "state": "finished", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "global_yes": false, - "global_no": true, - "global_abstain": false, - "onehundred_percent_base": "valid", - "votesvalid": "1.000000", - "votesinvalid": "0.000000", - "votescast": "1.000000", - "content_object_id": "assignment/2", - "voted_ids": [ - 1 - ], - "entitled_group_ids": [ - 2 - ], - "option_ids": [ - 11, - 12 + "anonymized": false, + "live_voting_enabled": false, + "published": false, + "allow_invalid": false, + "allow_vote_split": false, + "content_object_id": "assignment/1", + "ballot_ids": [ + 4, + 5, + 6, + 7, + 8 ], - "global_option_id": 13, - "meeting_id": 1, - "live_voting_enabled": false + "entitled_group_ids": [2], + "meeting_id": 1 + }, + "6": { + "id": 6, + "title": "6", + "config_id": "poll_config_stv_scottish/1", + "visibility": "secret", + "state": "created", + "anonymized": false, + "live_voting_enabled": false, + "published": false, + "allow_invalid": false, + "allow_vote_split": true, + "content_object_id": "assignment/1", + "entitled_group_ids": [2], + "meeting_id": 1 } }, - "option": { + "poll_config_approval": { "1": { "id": 1, - "yes": "2.000000", - "no": "4.000000", - "abstain": "1.000000", - "weight": 1, "poll_id": 1, - "content_object_id": "motion/1", - "vote_ids": [ - 1, - 2, - 3 - ], - "meeting_id": 1 + "allow_abstain": false, + "onehundred_percent_base": "valid" }, "2": { "id": 2, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", + "poll_id": 2, + "allow_abstain": true, + "onehundred_percent_base": "valid" + } + }, + "poll_config_selection": { + "1": { + "id": 1, + "poll_id": 3, + "max_options_amount": 2, + "min_options_amount": 1, + "allow_nota": true, + "strike_out": false, + "onehundred_percent_base": "valid" + } + }, + "poll_config_rating_score": { + "1": { + "id": 1, + "poll_id": 4, + "max_options_amount": 2, + "min_options_amount": 2, + "max_votes_per_option": 0, + "max_vote_sum": 5, + "min_vote_sum": 1, + "onehundred_percent_base": "valid" + } + }, + "poll_config_rating_approval": { + "1": { + "id": 1, + "poll_id": 5, + "max_options_amount": 0, + "min_options_amount": 0, + "allow_abstain": true, + "onehundred_percent_base": "valid" + } + }, + "poll_config_stv_scottish": { + "1": { + "id": 1, + "poll_id": 6, + "posts": 4 + } + }, + "poll_option": { + "1": { + "id": 1, "weight": 1, - "used_as_global_option_in_poll_id": 1, - "meeting_id": 1 + "poll_id": 2, + "meeting_user_id": 1 + }, + "2": { + "id": 2, + "weight": 2, + "poll_id": 2, + "meeting_user_id": 3 }, "3": { "id": 3, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, - "poll_id": 2, - "content_object_id": "motion/1", - "meeting_id": 1 + "poll_id": 3, + "text": "Option 3" }, "4": { "id": 4, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, - "used_as_global_option_in_poll_id": 2, - "meeting_id": 1 + "poll_id": 3, + "text": "Option 4" }, "5": { "id": 5, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, "poll_id": 3, - "content_object_id": "user/1", - "meeting_id": 1 + "text": "Option 5" }, "6": { "id": 6, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, - "used_as_global_option_in_poll_id": 3, - "meeting_id": 1 + "poll_id": 4, + "text": "option 6" }, "7": { "id": 7, - "yes": "3.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, "poll_id": 4, - "content_object_id": "user/1", - "vote_ids": [ - 4 - ], - "meeting_id": 1 + "text": "option 7" }, "8": { "id": 8, - "yes": "7.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 2, - "poll_id": 4, - "content_object_id": "user/3", - "vote_ids": [ - 5 - ], - "meeting_id": 1 + "poll_id": 5, + "meeting_user_id": 1 }, "9": { "id": 9, - "yes": "2.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 3, - "poll_id": 4, - "content_object_id": "user/2", - "vote_ids": [ - 6 - ], - "meeting_id": 1 + "poll_id": 5, + "meeting_user_id": 2 }, "10": { "id": 10, - "yes": "0.000000", - "no": "2.000000", - "abstain": "1.000000", - "weight": 1, - "used_as_global_option_in_poll_id": 4, - "vote_ids": [ - 7, - 8 - ], - "meeting_id": 1 - }, - "11": { - "id": 11, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, "poll_id": 5, - "content_object_id": "user/3", - "meeting_id": 1 - }, - "12": { - "id": 12, - "yes": "1.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 2, - "poll_id": 5, - "content_object_id": "user/2", - "vote_ids": [ - 9 - ], - "meeting_id": 1 - }, - "13": { - "id": 13, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "weight": 1, - "used_as_global_option_in_poll_id": 5, - "meeting_id": 1 + "meeting_user_id": 3 } }, - "vote": { + "poll_ballot": { "1": { "id": 1, "weight": "2.000000", - "value": "Y", - "user_token": "SNuxJc7W93bnhAiA", - "option_id": 1, - "meeting_id": 1 + "value": "yes", + "split": false, + "poll_id": 1 }, "2": { "id": 2, "weight": "4.000000", - "value": "N", - "user_token": "4bgn4RBjNlIeO7vj", - "option_id": 1, - "meeting_id": 1 + "value": "no", + "split": false, + "poll_id": 1 }, "3": { "id": 3, "weight": "1.000000", - "value": "A", - "user_token": "xLBFgo3O1pAfGZ0h", - "option_id": 1, - "meeting_id": 1 + "value": "abstain", + "split": false, + "poll_id": 1 }, "4": { "id": 4, - "value": "Y", + "value": "{\"1\":\"yes\",\"2\":\"abstain\",\"3\":\"yes\"}", "weight": "3.000000", - "user_token": "neT9r5YkT9U8yJfa", - "option_id": 7, - "meeting_id": 1 + "split": false, + "poll_id": 5 }, "5": { "id": 5, - "value": "Y", + "value": "{\"1\":\"no\",\"2\":\"yes\",\"3\":\"yes\"}", "weight": "7.000000", - "user_token": "U5YSuLUI1G5rNOHn", - "option_id": 8, - "meeting_id": 1 + "split": false, + "poll_id": 5 }, "6": { "id": 6, - "value": "Y", + "value": "{\"1\":\"yes\",\"2\":\"no\",\"3\":\"no\"}", "weight": "2.000000", - "user_token": "jkNKIiJr8Dl0yOXI", - "option_id": 9, - "meeting_id": 1 + "split": false, + "poll_id": 5 }, "7": { "id": 7, - "value": "N", + "value": "{\"1\":\"no\",\"2\":\"no\",\"3\":\"yes\"}", "weight": "2.000000", - "user_token": "Z1cxOviuelzPT2rm", - "option_id": 10, - "meeting_id": 1 + "split": false, + "poll_id": 5 }, "8": { "id": 8, - "value": "A", + "value": "{\"1\":\"abstain\",\"2\":\"yes\",\"3\":\"abstain\"}", "weight": "1.000000", - "user_token": "daUZh16fXCAu5DBL", - "option_id": 10, - "meeting_id": 1 + "split": false, + "poll_id": 5 }, "9": { "id": 9, - "value": "Y", + "value": "1", "weight": "1.000000", - "user_token": "ivgipZ18D9Xac8pd", - "user_id": 1, - "delegated_user_id": 1, - "option_id": 12, - "meeting_id": 1 + "split": false, + "poll_id": 3, + "poll_ballot_user_id": 1 } - }, + }, + "poll_ballot_user": { + "1": { + "id": 1, + "acting_meeting_user_id": 1, + "represented_meeting_user_id": 1, + "poll_id": 3, + "poll_ballot_id": 9 + } + }, "assignment": { "1": { "id": 1, "title": "Wahl", "open_posts": 1, "phase": "voting", - "candidate_ids": [ + "candidate_ids": [ 1, 2, 3 ], "poll_ids": [ - 3, - 4 + 2, + 5, + 6 ], "agenda_item_id": 11, "list_of_speakers_id": 11, @@ -2298,13 +2221,11 @@ "open_posts": 1, "phase": "search", "number_poll_candidates": true, - "candidate_ids": [ + "candidate_ids": [ 4, 5 ], - "poll_ids": [ - 5 - ], + "poll_ids": [], "agenda_item_id": 14, "list_of_speakers_id": 14, "tag_ids": [ @@ -2401,7 +2322,7 @@ "show_title": true, "show_logo": true, "show_clock": true, - "current_projection_ids": [ + "current_projection_ids": [ 3, 4 ], @@ -2421,7 +2342,7 @@ "used_as_default_projector_for_countdown_in_meeting_id": 1, "used_as_default_projector_for_assignment_poll_in_meeting_id": 1, "used_as_default_projector_for_motion_poll_in_meeting_id": 1, - "used_as_default_projector_for_poll_in_meeting_id": 1, + "used_as_default_projector_for_topic_poll_in_meeting_id": 1, "meeting_id": 1 }, "2": { @@ -2446,7 +2367,7 @@ "show_title": true, "show_logo": true, "show_clock": true, - "used_as_default_projector_for_list_of_speakers_in_meeting_id": 1, + "used_as_default_projector_for_list_of_speakers_in_meeting_id": 1, "used_as_default_projector_for_current_los_in_meeting_id": 1, "meeting_id": 1 } diff --git a/dev/dc.local.yml b/dev/dc.local.yml index cc50de7cf4..23dc2f54b9 100644 --- a/dev/dc.local.yml +++ b/dev/dc.local.yml @@ -1,4 +1,4 @@ -# Use local auth and vote service +# Use local auth service version: "3" services: backend: @@ -12,11 +12,6 @@ services: environment: - PYTHONPATH=/app:/osauthlib - MYPYPATH=/app:/osauthlib - vote: - build: - context: ../../openslides-vote-service - args: - CONTEXT: "prod" auth: build: context: ../../openslides-auth-service diff --git a/dev/docker-compose.dev.yml b/dev/docker-compose.dev.yml index 96a5fd62a5..fb01acef09 100644 --- a/dev/docker-compose.dev.yml +++ b/dev/docker-compose.dev.yml @@ -28,7 +28,6 @@ services: depends_on: - postgres - auth - - vote auth: build: context: "https://github.com/OpenSlides/openslides-auth-service.git#${COMPOSE_REFERENCE_BRANCH:-main}" @@ -48,26 +47,6 @@ services: - CACHE_HOST=redis depends_on: - redis - vote: - build: - context: "https://github.com/OpenSlides/openslides-vote-service.git#${COMPOSE_REFERENCE_BRANCH:-main}" - target: "dev" - args: - CONTEXT: "dev" - image: openslides-vote-dev - ports: - - "9013:9013" - environment: - - OPENSLIDES_DEVELOPMENT=1 - - MESSAGING=redis - - AUTH_HOST=auth - - DATABASE_HOST=postgres - - VOTE_DATABASE_HOST=postgres - - MESSAGE_BUS_HOST=redis - - CACHE_HOST=redis - depends_on: - - redis - - auth postgres: image: postgres:17 command: ["postgres", "-c", "log_statement=all"] diff --git a/docs/Actions-Overview.md b/docs/Actions-Overview.md index 38b87b1064..f37c9221cd 100644 --- a/docs/Actions-Overview.md +++ b/docs/Actions-Overview.md @@ -78,6 +78,12 @@ A more general format description see in [Action-Service](https://github.com/Ope - [meeting.archive](actions/meeting.archive.md) - [meeting.unarchive](actions/meeting.unarchive.md) +## Meeting poll defaults + +- [meeting_poll_default.create](actions/meeting_poll_default.create.md) +- [meeting_poll_default.update](actions/meeting_poll_default.delete.md) +- [meeting_poll_default.update](actions/meeting_poll_default.update.md) + ## Motions - [motion.create](actions/motion.create.md) @@ -142,6 +148,7 @@ A more general format description see in [Action-Service](https://github.com/Ope - [personal_note.update](actions/personal_note.update.md) ## Projector and Projection + - [projector.create](actions/projector.create.md) - [projector.update](actions/projector.update.md) - [projector.delete](actions/projector.delete.md) @@ -171,6 +178,18 @@ A more general format description see in [Action-Service](https://github.com/Ope - [point_of_order_category.delete](actions/point_of_order_category.delete.md) - [point_of_order_category.update](actions/point_of_order_category.update.md) +## Poll related objects + +- [poll.delete](actions/poll.delete.md) +- [poll_ballot.delete](actions/poll_ballot.delete.md) +- [poll_ballot_user.delete](actions/poll_ballot_user.delete.md) +- [poll_config_approval.delete](actions/poll_config_approval.delete.md) +- [poll_config_rating_approval.delete](actions/poll_config_rating_approval.delete.md) +- [poll_config_rating_score.delete](actions/poll_config_rating_score.delete.md) +- [poll_config_selection.delete](actions/poll_config_selection.delete.md) +- [poll_config_stv_scottish.delete](actions/poll_config_stv_scottish.delete.md) +- [poll_option.delete](actions/poll_option.delete.md) + ## Speaker - [speaker.create](actions/speaker.create.md) @@ -219,6 +238,7 @@ A more general format description see in [Action-Service](https://github.com/Ope - [topic.import](actions/topic.import.md) ## Users + - [user.assign_meetings](actions/user.assign_meetings.md) - [user.create](actions/user.create.md) - [user.delete](actions/user.delete.md) @@ -243,21 +263,3 @@ A more general format description see in [Action-Service](https://github.com/Ope - [account.import](actions/account.import.md) - [participant.json_upload](actions/participant.json_upload.md) - [participant.import](actions/participant.import.md) - -## Voting - -- [option.update](actions/option.update.md) -- [poll.create](actions/poll.create.md) -- [poll.delete](actions/poll.delete.md) -- [poll.update](actions/poll.update.md) -- [poll.start](actions/poll.start.md) -- [poll.stop](actions/poll.stop.md) -- [poll.publish](actions/poll.publish.md) -- [poll.reset](actions/poll.reset.md) -- [poll.anonymize](actions/poll.anonymize.md) -- [poll.vote](actions/poll.vote.md) -- [poll_candidate_list.create](actions/poll_candidate_list.create.md) -- [poll_candidate_list.delete](actions/poll_candidate_list.delete.md) -- [poll_candidate.create](actions/poll_candidate.create.md) -- [poll_candidate.delete](actions/poll_candidate.delete.md) - diff --git a/docs/actions/meeting.create.md b/docs/actions/meeting.create.md index 650cff4f90..cab505f6a8 100644 --- a/docs/actions/meeting.create.md +++ b/docs/actions/meeting.create.md @@ -24,15 +24,16 @@ Creates a meeting. Checks whether the `organization.limit_of_meetings` is unlimited (=0) or lower than the amount of active meetings in `organization.active_meeting_ids`. - + `time_zone` must be a valid timezone string (see [presenter](../presenters/get_valid_timezones.md)). The following objects are created, too: -- Groups: `Default`, `Admin`, `Delegates`, `Staff`, `Committees`. The first one is set as `meeting/default_group_id`, the second one as `meeting/admin_group_id`. The permissions can be found in the [initial-data.json](https://github.com/OpenSlides/openslides-backend/tree/main/data/initial-data.json)). +- Groups: `Default`, `Admin`, `Delegates`, `Staff`, `Committees`. The first one is set as `meeting/default_group_id`, the second one as `meeting/admin_group_id`. The permissions can be found in the [initial-data.json](https://github.com/OpenSlides/openslides-backend/tree/main/data/initial-data.json). - Projector: One projector named `"Default projector"` is created and set as `meeting/reference_projector_id`. - All default projectors (`meeting/default_projector_*_ids`, see `models.yml`) are set to that one projector - Motion workflow and states: Creates one workflow `"simple workflow"` which is set as `meeting/motions_default_workflow_id` and `meeting/motions_default_amendment_workflow_id`. Creates four states (analog as in the [initial-data.json](https://github.com/OpenSlides/openslides-backend/tree/main/data/initial-data.json)). - Two countdowns are created and set as `meeting/list_of_speakers_countdown` (name: "List of speakers countdown") and `meeting/voting_countdown` (name: "Voting countdown"). +- 3 `meeting_poll_default` items: one for each poll type (assignment, motion, topic). If `user_ids` are given, it checks if it is a subset of `committee/user_ids`. Each user is added to the meeting by being added to the default group. @@ -41,16 +42,17 @@ If they aren't given and `set_as_template` is not true, there will be an error. The field `is_active_in_organization_id` is set to the organization_id. -If a meeting is created, -* `motion_poll_default_type` is `pseudoanonymous` -* `motion_poll_default_method` is `YNA` -* `assignment_poll_default_type` is `pseudoanonymous` -* `assignment_poll_default_method` is `Y` -* `assignment_poll_default_group_ids`, `motion_poll_default_group_ids` and `topic_poll_default_group_ids` have the Delegates group. +If a meeting is created, defaults are set for `meeting_poll_default`: +* `group_ids` is the Delegates group +* for topic polls: + * `visibility` is `manually` + * `display_chart` is `pie` +* for motion and topic polls: + * `visibility` is `secret` If `set_as_template` is given, `template_for_organization_id` is set to `1`. -Created objects (eg. groups, workflow, states, ...) are translated to the given language `language`. +Created objects (eg. groups, workflow, states, ...) are translated to the given language `language`. ## Permissions The user must have CML `can_manage`. diff --git a/docs/actions/meeting.update.md b/docs/actions/meeting.update.md index f7eb78664c..6330646f62 100644 --- a/docs/actions/meeting.update.md +++ b/docs/actions/meeting.update.md @@ -118,15 +118,8 @@ motions_enable_restricted_editor_for_manager: boolean; motions_enable_restricted_editor_for_non_manager: boolean; - motion_poll_ballot_paper_selection: string; - motion_poll_ballot_paper_number: number; - motion_poll_default_type: string; - motion_poll_default_method: string; - motion_poll_default_onehundred_percent_base: string; - motion_poll_default_group_ids: Id[]; - motion_poll_default_backend: string; - motion_poll_projection_name_order_first: string; - motion_poll_projection_max_columns: number; + poll_projection_name_order_first: string; + poll_projection_max_columns: number; users_enable_presence_view: boolean; users_enable_vote_weight: boolean; @@ -149,21 +142,41 @@ assignments_export_title: string; assignments_export_preamble: string; - assignment_poll_ballot_paper_selection: string; - assignment_poll_ballot_paper_number: number; assignment_poll_add_candidates_to_list_of_speakers: boolean; - assignment_poll_enable_max_votes_per_option: boolean; - assignment_poll_sort_poll_result_by_votes: boolean; - assignment_poll_default_type: string; assignment_poll_default_method: string; - assignment_poll_default_onehundred_percent_base: string; + assignment_poll_default_allow_abstain: boolean; + assignment_poll_default_allow_nota: boolean; + assignment_poll_default_display_chart: string; assignment_poll_default_group_ids: Id[]; - assignment_poll_default_backend: string; + assignment_poll_default_onehundred_percent_base: string; + assignment_poll_default_sort_result_by_votes: boolean; + assignment_poll_default_strike_out: boolean; + assignment_poll_default_visibility: string; + motion_poll_default_allow_abstain: boolean; + motion_poll_default_allow_nota: boolean; + motion_poll_default_display_chart: string; + motion_poll_default_group_ids: Id[]; + motion_poll_default_onehundred_percent_base: string; + motion_poll_default_sort_result_by_votes: boolean; + motion_poll_default_strike_out: boolean; + motion_poll_default_visibility: string; + + topic_poll_default_method: string; + topic_poll_default_allow_abstain: boolean; + topic_poll_default_allow_nota: boolean; + topic_poll_default_display_chart: string; topic_poll_default_group_ids: Id[]; + topic_poll_default_onehundred_percent_base: string; + topic_poll_default_sort_result_by_votes: boolean; + topic_poll_default_strike_out: boolean; + topic_poll_default_visibility: string; - poll_default_backend: string; + poll_enable_max_votes_per_option: boolean; poll_default_live_voting_enabled: boolean; + poll_default_allow_invalid: boolean; + poll_default_allow_vote_split: boolean; + // Group B present_user_ids: user/is_present_in_meeting_ids; @@ -183,7 +196,7 @@ default_projector_countdown_ids: Ids; default_projector_assignment_poll_ids: Ids; default_projector_motion_poll_ids: Ids; - default_projector_poll_ids: Ids; + default_projector_topic_poll_ids: Ids; // Group D external_id: string; // unique in org diff --git a/docs/actions/meeting_poll_default.create.md b/docs/actions/meeting_poll_default.create.md new file mode 100644 index 0000000000..8831a991f9 --- /dev/null +++ b/docs/actions/meeting_poll_default.create.md @@ -0,0 +1,43 @@ +## Payload +```js +{ +// required + meeting_id: Id; + +// exactly 1 out of 3 is required + used_as_assignment_poll_config_in_meeting_id: Id; + used_as_motion_poll_config_in_meeting_id: Id; + used_as_topic_poll_config_in_meeting_id: Id; + +// optional + allow_abstain: boolean; + allow_nota: boolean; + display_chart: string; + group_ids: Id[] + onehundred_percent_base: string; + sort_result_by_votes: boolean; + strike_out: boolean; + visibility: string; +} +``` + +## Internal action + +The action creates `meeting_poll_default` item for the meeting. Should only be called by meeting.create or meeting.update. + +Along with the `meeting_id` field, exactly one out of the 3 field must be defined: + +* used_as_assignment_poll_config_in_meeting_id +* used_as_motion_poll_config_in_meeting_id +* used_as_topic_poll_config_in_meeting_id + +Value in the field used_as_*_poll_config_in_meeting_id must be the same as `meeting_id`. + +All ids in `group_ids` must bellong to the meeting defined by `meeting_id`. + +This action sets default values based on the poll type for the fields listed below if no value is given for them in the payload: + +* display_chart (only if `used_as_topic_poll_config_in_meeting_id` is set) => "pie" +* visibility: + * if `used_as_topic_poll_config_in_meeting_id` is set => "manually" + * else => "secret" diff --git a/docs/actions/meeting_poll_default.delete.md b/docs/actions/meeting_poll_default.delete.md new file mode 100644 index 0000000000..de99be2e6a --- /dev/null +++ b/docs/actions/meeting_poll_default.delete.md @@ -0,0 +1,8 @@ +## Payload +```js +{ id: Id; } +``` + +## Internal action + +Delete a `meeting_poll_default` item. diff --git a/docs/actions/meeting_poll_default.update.md b/docs/actions/meeting_poll_default.update.md new file mode 100644 index 0000000000..585c406eba --- /dev/null +++ b/docs/actions/meeting_poll_default.update.md @@ -0,0 +1,22 @@ +## Payload +```js +{ +// optional + allow_abstain: boolean; + allow_nota: boolean; + display_chart: string; + group_ids: Id[] + onehundred_percent_base: string; + sort_result_by_votes: boolean; + strike_out: boolean; + visibility: string; +} +``` + +## Internal action + +The action updates a `meeting_poll_default` item. + +Should only be called by meeting.update. + +All ids in `group_ids` must bellong to the meeting defined by `meeting_id`. diff --git a/docs/actions/meeting_user.update.md b/docs/actions/meeting_user.update.md index a96c32e0b6..d632eac4fe 100644 --- a/docs/actions/meeting_user.update.md +++ b/docs/actions/meeting_user.update.md @@ -5,6 +5,10 @@ id: Id; // Optional + poll_option_ids: Id[]; + acting_ballot_ids: Id[]; + represented_ballot_ids: Id[]; + // Group A number: string; structure_level_ids: Id[]; diff --git a/docs/actions/option.update.md b/docs/actions/option.update.md deleted file mode 100644 index d34d66b473..0000000000 --- a/docs/actions/option.update.md +++ /dev/null @@ -1,23 +0,0 @@ -## Payload -```js -{ -// Required - id: Id; -// Optional. - Y: number; - N: number; - A: number; - publish_immediately: boolean; -} -``` - -## Action -This action is only allowed for analog polls. Updating this option changes the associated `vote` objects for `Y`, `N` and `A` to the given values. - -If the poll's state is *created* and at least one vote value is given (`Y`, `N` or `A`), the state must be set to *finished*. if additionally `publish_immediately` is given, the state must be set to *published*. - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.anonymize.md b/docs/actions/poll.anonymize.md deleted file mode 100644 index 33a32283b0..0000000000 --- a/docs/actions/poll.anonymize.md +++ /dev/null @@ -1,15 +0,0 @@ -## Payload -```js -{id: Id;} -``` - -## Action -Only for non-analog polls in the state *finished* or *published*. Sets all `vote/user_id` and `vote/delegated_user_id` references to `None` for each vote of each option of the poll (including the global option). - -`is_pseudoanonymized` has to be set to `true`. - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.create.md b/docs/actions/poll.create.md deleted file mode 100644 index 5b7a729f3b..0000000000 --- a/docs/actions/poll.create.md +++ /dev/null @@ -1,82 +0,0 @@ -## Payload - -Helper Interface for options to create: -```js -Interface Option { - // Exactly one of text, content_object_id or poll_candidate_user_ids must be given - text: string, // topic-poll - content_object_id: Fqid, // must be one of user or motion. - poll_candidate_user_ids: [user_ids], // sorted list of user ids for candidate list election - - // Optionally and only for type==analog, votes can be given - Y: decimal(6), // Y, YN, YNA mode - N: decimal(6), // N, YN, YNA mode - A: decimal(6) // YNA mode -} -``` - -Payload: -```js -{ -// Required - title: string, - type: string, - pollmethod: string, - - meeting_id: Id, - options: Option[], // must have at least one entry. - -// Optional - content_object_id: Fqid, - description: string, - min_votes_amount: number, - max_votes_amount: number, - max_votes_per_option: number, - global_yes: boolean, - global_no: boolean, - global_abstain: boolean, - onehundred_percent_base: string, - backend: string, - -// Optional, only for type==named - live_voting_enabled: boolean, - -// Only for non analog types - entitled_group_ids: Id[], - -// Only for type==analog - publish_immediately: boolean, - -// Optionally and only for type==analog, votes can be given - votesvalid: decimal(6), - votesinvalid: decimal(6), - votescast: decimal(6), - amount_global_yes: decimal(6), - amount_global_no: decimal(6), - amount_global_abstain: decimal(6) -} -``` - -## Action -If an analog poll with votes is given, the state is set to `finished` if at least one vote value is given. if `publish_immediately` is true and some vote value is given, the state is set to `published`. All options given are created as instances of the `option` model. If some options have values (for analog polls), `vote` objects have to be created, one for each option and vote value (`Y`, `N`, `A`). - -The options must be unique in the way that each non-empty `text` and non-empty `content_object_id` can only exist once. The `option/weight` is set in the order the options are given in the payload. A global option is created. - -If the `type` is `pseudoanonymous`, `is_pseudoanonymized` is set to `true`. - -If the `content_object_id` points to a `motion` and the `motion_state` of the motion misses `allow_create_poll`, it is forbidden to create a poll. - -The `entitled_group_ids` may not contain the meetings `anonymous_group_id`. - -The `max_votes_per_option` and `min_votes_amount` must be smaller or equal to `max_votes_amount`. - -The `live_voting_enabled` could be set for named motion and following named assignment polls: -- polls with pollmethod `Y` and not `global_yes` and `max_votes_amount` of 1 -- polls with pollmethod `YNA` or `YN` and only one option -- list polls - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.delete.md b/docs/actions/poll.delete.md index b8a20ff1d1..bcde1c797b 100644 --- a/docs/actions/poll.delete.md +++ b/docs/actions/poll.delete.md @@ -1,13 +1,9 @@ ## Payload + ```js { id: Id; } ``` -## Action -Deletes the given poll and all linked options with all votes, too. +## Internal action -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic +Deletes a poll. diff --git a/docs/actions/poll.publish.md b/docs/actions/poll.publish.md deleted file mode 100644 index df746c673a..0000000000 --- a/docs/actions/poll.publish.md +++ /dev/null @@ -1,13 +0,0 @@ -## Payload -```js -{id: Id;} -``` - -## Action -Sets the state to *published*. Only allowed for polls in the *finished* state. - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.reset.md b/docs/actions/poll.reset.md deleted file mode 100644 index 50516579a8..0000000000 --- a/docs/actions/poll.reset.md +++ /dev/null @@ -1,15 +0,0 @@ -## Payload -```js -{id: Id;} -``` - -## Action -Sets the state to *created*. Only allowed for polls in the *finished* or *published* state. All vote objects of all options (including the global option) are deleted. - -If `type != "pseudoanonymous"`, `is_pseudoanonymized` may be reset to `false` (if it was previously `true`). - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.start.md b/docs/actions/poll.start.md deleted file mode 100644 index 5f98abde1a..0000000000 --- a/docs/actions/poll.start.md +++ /dev/null @@ -1,15 +0,0 @@ -## Payload -```js -{id: Id;} -``` - -## Action -Sets the state to *started*. Only allowed for polls in the *created* state. - -If `meeting/poll_couple_countdown` is true and the poll is an electronic poll, the countdown given by `meeting/poll_countdown_id` must be *restarted* (see [Countdowns](https://github.com/OpenSlides/OpenSlides/wiki/Countdowns#restart-a-countdown)). - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.stop.md b/docs/actions/poll.stop.md deleted file mode 100644 index b1986e1fee..0000000000 --- a/docs/actions/poll.stop.md +++ /dev/null @@ -1,19 +0,0 @@ -## Payload -```js -{id: Id;} -``` - -## Action -Sets the state to *finished*. Only allowed for polls in the *started* state. - -If `meeting/poll_couple_countdown` is true, the countdown given by `meeting/poll_countdown_id` is *reset* (see [Countdowns](https://github.com/OpenSlides/OpenSlides/wiki/Countdowns#reset-a-countdown)). - -Some fields are calculated upon stopping a poll: -- The fields `votescast`, `votesvalid` and `votesinvalid` are filled (see [poll results](https://github.com/OpenSlides/OpenSlides/wiki/Voting#poll-results)). They are only filled once when the poll stops to prevent any changes e.g. from deleting users. -- `entitled_users_at_stop` is filled. It is an array of objects which represents all users entitled to vote at the stopping point of the poll. The syntax is `{"user_id": Id, "voted": boolean, "vote_delegated_to_id": Id | null}`. The fields should be self-explanatory. This field is also a snapshot like the ones above. - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll.update.md b/docs/actions/poll.update.md deleted file mode 100644 index 5c4953d4b5..0000000000 --- a/docs/actions/poll.update.md +++ /dev/null @@ -1,57 +0,0 @@ -## Payload -```js -{ -// Required - id: Id, - -// Optional, only if state == created - pollmethod: string, - min_votes_amount: number, - max_votes_amount: number, - max_votes_per_option: number, - global_yes: boolean, - global_no: boolean, - global_abstain: boolean, - backend: string, - -// Optional, only if state == created, only for non analog types - entitled_group_ids: Id[], - -// Optional, every state - title: string, - description: string, - onehundred_percent_base: string, - -// Optional, type==analog, every state - votesvalid: number, - votesinvalid: number, - votescast: number, - publish_immediately: boolean, - -// Optional, type==named - live_voting_enabled: boolean - -// action called internally - entitled_users_at_stop: json -} -``` - -## Action -For analog polls: If the state is created and at least one vote value is given (`votesvalid`/`votesinvalid`/`votescast`), the state is set to finished. If additionally `publish_immediately` is given, the state is set to published. - -For electronic polls some fields can only be updated, if the state is *created*. - -The `entitled_group_ids` may not contain the meetings `anonymous_group_id`. - -The `max_votes_per_option` and `min_votes_amount` must be smaller or equal to `max_votes_amount` after the model has been updated. - -The `live_voting_enabled` could be set for named motion and following named assignment polls: -- polls with pollmethod `Y` and not `global_yes` and `max_votes_amount` of 1 -- polls with pollmethod `YNA` or `YN` and only one option -- list polls - -## Permissions -The request user needs: -- `motion.can_manage_polls` if the poll's content object is a motion -- `assignment.can_manage_polls` if the poll's content object is an assignment -- `poll.can_manage` if the poll's content object is a topic diff --git a/docs/actions/poll_ballot.delete.md b/docs/actions/poll_ballot.delete.md new file mode 100644 index 0000000000..9574c2f761 --- /dev/null +++ b/docs/actions/poll_ballot.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_ballot. diff --git a/docs/actions/poll_ballot_user.delete.md b/docs/actions/poll_ballot_user.delete.md new file mode 100644 index 0000000000..f73c6b9778 --- /dev/null +++ b/docs/actions/poll_ballot_user.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_ballot_user. diff --git a/docs/actions/poll_candidate.create.md b/docs/actions/poll_candidate.create.md deleted file mode 100644 index fdf4c0b674..0000000000 --- a/docs/actions/poll_candidate.create.md +++ /dev/null @@ -1,14 +0,0 @@ -## Payload - -```js -{ - // Required - user_id: Id; - poll_candidate_list_id: Id; - weight: number; - meeting_id: Id; -} -``` - -## Internal action -Creates a poll candidate. diff --git a/docs/actions/poll_candidate.delete.md b/docs/actions/poll_candidate.delete.md deleted file mode 100644 index 1862c0eee8..0000000000 --- a/docs/actions/poll_candidate.delete.md +++ /dev/null @@ -1,13 +0,0 @@ -## Payload - -Payload: -```js -{ - // Required - id: Id; -} -``` - -## Internal action -Deletes a poll candidate with the given id. - \ No newline at end of file diff --git a/docs/actions/poll_candidate_list.create.md b/docs/actions/poll_candidate_list.create.md deleted file mode 100644 index b11a0e3597..0000000000 --- a/docs/actions/poll_candidate_list.create.md +++ /dev/null @@ -1,17 +0,0 @@ -## Payload - -Payload: -```js -{ - // Required - option_id: Id, - meeting_id: Id, - entries: [{ - user_id: Id, - weight: number - }] -} -``` - -## Internal action -Creates poll candidates for the entries and creates a `poll_candidate_list`. diff --git a/docs/actions/poll_candidate_list.delete.md b/docs/actions/poll_candidate_list.delete.md deleted file mode 100644 index ff65d8eda2..0000000000 --- a/docs/actions/poll_candidate_list.delete.md +++ /dev/null @@ -1,12 +0,0 @@ -## Payload - -Payload: -```js -{ - // Required - id: Id; -} -``` - -## Internal action -Deletes the `poll_candidate_list` with the given id and all poll candidates which belong to this list. \ No newline at end of file diff --git a/docs/actions/poll_config_approval.delete.md b/docs/actions/poll_config_approval.delete.md new file mode 100644 index 0000000000..4a95f4ab5b --- /dev/null +++ b/docs/actions/poll_config_approval.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_config_approval. diff --git a/docs/actions/poll_config_rating_approval.delete.md b/docs/actions/poll_config_rating_approval.delete.md new file mode 100644 index 0000000000..28e868cd0a --- /dev/null +++ b/docs/actions/poll_config_rating_approval.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_config_rating_approval. diff --git a/docs/actions/poll_config_rating_score.delete.md b/docs/actions/poll_config_rating_score.delete.md new file mode 100644 index 0000000000..f4aeb1b4d5 --- /dev/null +++ b/docs/actions/poll_config_rating_score.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_config_rating_score. diff --git a/docs/actions/poll_config_selection.delete.md b/docs/actions/poll_config_selection.delete.md new file mode 100644 index 0000000000..9dfc2a7695 --- /dev/null +++ b/docs/actions/poll_config_selection.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_config_selection. diff --git a/docs/actions/poll_config_stv_scottish.delete.md b/docs/actions/poll_config_stv_scottish.delete.md new file mode 100644 index 0000000000..21e29f74c4 --- /dev/null +++ b/docs/actions/poll_config_stv_scottish.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_config_stv_scottish. diff --git a/docs/actions/poll_option.delete.md b/docs/actions/poll_option.delete.md new file mode 100644 index 0000000000..0bdc8a82be --- /dev/null +++ b/docs/actions/poll_option.delete.md @@ -0,0 +1,9 @@ +## Payload + +```js +{ id: Id; } +``` + +## Internal action + +Deletes a poll_option. diff --git a/docs/actions/projector.create.md b/docs/actions/projector.create.md index 5f6a282b9c..f7e639a54a 100644 --- a/docs/actions/projector.create.md +++ b/docs/actions/projector.create.md @@ -36,7 +36,7 @@ used_as_default_projector_for_countdown_in_meeting_id: Id, used_as_default_projector_for_assignment_poll_in_meeting_id: Id, used_as_default_projector_for_motion_poll_in_meeting_id: Id, - used_as_default_projector_for_poll_in_meeting_id: Id, + used_as_default_projector_for_topic_poll_in_meeting_id: Id, } ``` diff --git a/docs/actions/projector.update.md b/docs/actions/projector.update.md index 3f6a1dfb44..8e29556f2f 100644 --- a/docs/actions/projector.update.md +++ b/docs/actions/projector.update.md @@ -37,7 +37,7 @@ used_as_default_projector_for_countdown_in_meeting_id: Id, used_as_default_projector_for_assignment_poll_in_meeting_id: Id, used_as_default_projector_for_motion_poll_in_meeting_id: Id, - used_as_default_projector_for_poll_in_meeting_id: Id, + used_as_default_projector_for_topic_poll_in_meeting_id: Id, } ``` diff --git a/docs/actions/user.merge_together.md b/docs/actions/user.merge_together.md index 2dacb90eea..5846030064 100644 --- a/docs/actions/user.merge_together.md +++ b/docs/actions/user.merge_together.md @@ -42,11 +42,10 @@ An error is thrown if: - Any of the secondary users have a `saml_id` - There are multiple different `member_number`s between the selected users (empty does not count) - There are conflicts regarding polls, i.e. two or more of the selected users... - - are option content_objects (not counting poll_candidate_list membership) on the same poll - - are candidates on the same poll_candidate_list - - have voted on the same poll (delegated or not) - Any affected meeting_users have groups that are currently entitled to work on any poll - Any affected meeting_users _who share a meeting_: + - are meeting_users of the poll_option on the same poll + - have voted on the same poll (delegated or not) - have running speakers - are in a meeting without `list_of_speakers_allow_multiple_speakers` and have waiting speakers on the same list who cannot be merged together into at most one point_of_order and one normal speech. Speeches may not be merged if there are multiple different values (empty does count) within any of the fields: - `speech_state` @@ -70,8 +69,6 @@ Any date in the custom payload data from the request overwrites anything that wo Data validity of the results is checked according to user.update rules. The secondary users are deleted. - -Any poll that contains the id of any secondary user in its `entitled_users_at_stop` list will have it re-written to _additionally_ contain the new user id. This means that a line `{"voted": false, "present": true, "user_id": 4, "vote_delegated_to_user_id": 7}` becomes @@ -80,7 +77,7 @@ after two merges where for the first `user/4` was merged into `user/2` and for t This is to ensure that the client can recognize where users were merged, as simply replacing the ids may cause situations where a user is present on a list twice and not replacing them would mean that the user that voted would not be recognizable anymore. #### Merging of sub-collections -Relation lists where simple unification does not suffice (usually because the target collections function mostly as a type of m:n connection between two other collections) are merged. +Relation lists where simple unification does not suffice (usually because the target collections function mostly as a type of m:n connection between two other collections) are merged. For that purpose, target models for these relations are compared and those that are judged to fulfill equivalent roles are grouped together. @@ -141,7 +138,7 @@ He also needs a organization management level that is equal or higher than that The client could/should fill the optional fields from a chosen "main" user to not force the editor to rewrite all the data. -Warnings should be shown alerting the user that +Warnings should be shown alerting the user that - this action is not reversable, - will potentially change/overwrite data in archived meetings and - will neither port the history information of the secondary users to the new ones nor rewrite the user id in the "Changed by" column \ No newline at end of file diff --git a/docs/actions/user.update.md b/docs/actions/user.update.md index 4244f4c135..a2fa5e1286 100644 --- a/docs/actions/user.update.md +++ b/docs/actions/user.update.md @@ -58,11 +58,6 @@ // only internal is_present_in_meeting_ids: Id[]; - option_ids: Id[]; - poll_candidate_ids: Id[]; - poll_voted_ids: Id[]; - vote_ids: Id[]; - delegated_vote_ids: Id[]; } ``` diff --git a/meta b/meta index 8d674fe32c..6d54f6540a 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit 8d674fe32cbadef2d572f11abf4cfa81f09c94e1 +Subproject commit 6d54f6540a661842e1af030ca0199aa6c0b4adad diff --git a/openslides_backend/action/action.py b/openslides_backend/action/action.py index 2e32659e16..fca7057fba 100644 --- a/openslides_backend/action/action.py +++ b/openslides_backend/action/action.py @@ -9,6 +9,7 @@ from psycopg.types.json import Jsonb from openslides_backend.shared.base_service_provider import BaseServiceProvider +from openslides_backend.shared.history_events import update_history_information_multi from ..models.base import Model, model_registry from ..models.fields import BaseRelationField, GenericRelationField @@ -435,8 +436,11 @@ def _build_write_request( # sort events: create - update - delete events_by_type: dict[EventType, list[Event]] = defaultdict(list) for event in self.events: - self.apply_event(event) - events_by_type[event["type"]].append(event) + if event["type"] == "delete" or not self.is_to_be_deleted( + event["fqid"] + ): + self.apply_event(event) + events_by_type[event["type"]].append(event) information = self.get_full_history_information() if self.is_sub_call: write_request.information = information @@ -540,7 +544,7 @@ def get_history_information(self) -> HistoryInformation | None: if self.history_information is None: return None - information = {} + information: HistoryInformation = {} instances = ( self.get_instances_with_fields(["id", self.history_relation_field]) if self.history_relation_field @@ -558,8 +562,9 @@ def get_history_information(self) -> HistoryInformation | None: fqids.append( fqid_from_collection_and_id(self.model.collection, instance["id"]) ) - for fqid in fqids: - information[fqid] = [self.history_information] + update_history_information_multi( + information, fqids, [self.history_information] + ) return information def get_instances_with_fields( @@ -763,6 +768,9 @@ def get_post_edit_function(self) -> EditFunction | None: """ return None + def is_to_be_deleted(self, fqid: FullQualifiedId) -> bool: + return self.datastore.is_to_be_deleted(fqid) + def merge_history_informations( a: HistoryInformation | None, *other: HistoryInformation | None @@ -777,7 +785,12 @@ def merge_history_informations( b = {} for fqid, information in b.items(): if fqid in a: - a[fqid].extend(information) + if entries := information.get("entries"): + a[fqid].setdefault("entries", list()).extend(entries) + if structured_information := information.get("structured_information"): + a[fqid].setdefault("structured_information", dict()).update( + structured_information + ) else: a[fqid] = information return a diff --git a/openslides_backend/action/actions/__init__.py b/openslides_backend/action/actions/__init__.py index d2c34501db..0604765ddf 100644 --- a/openslides_backend/action/actions/__init__.py +++ b/openslides_backend/action/actions/__init__.py @@ -20,6 +20,7 @@ def prepare_actions_map() -> None: mediafile, meeting, meeting_mediafile, + meeting_poll_default, meeting_user, motion, motion_block, @@ -33,14 +34,19 @@ def prepare_actions_map() -> None: motion_supporter, motion_workflow, motion_working_group_speaker, - option, organization, organization_tag, personal_note, point_of_order_category, poll, - poll_candidate, - poll_candidate_list, + poll_ballot, + poll_ballot_user, + poll_config_approval, + poll_config_rating_approval, + poll_config_rating_score, + poll_config_selection, + poll_config_stv_scottish, + poll_option, projection, projector, projector_countdown, @@ -52,7 +58,6 @@ def prepare_actions_map() -> None: theme, topic, user, - vote, ) diff --git a/openslides_backend/action/actions/group/delete.py b/openslides_backend/action/actions/group/delete.py index c9e292b136..c99c8a26a4 100644 --- a/openslides_backend/action/actions/group/delete.py +++ b/openslides_backend/action/actions/group/delete.py @@ -4,10 +4,10 @@ from openslides_backend.shared.exceptions import ActionException -from ....models.models import Group +from ....models.models import Group, Poll from ....permissions.permissions import Permissions from ....services.database.commands import GetManyRequest -from ....shared.filters import And, FilterOperator +from ....shared.filters import And, FilterOperator, Or from ....shared.interfaces.event import Event, EventType, ListFields from ....shared.patterns import ( FullQualifiedId, @@ -41,12 +41,24 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: "meeting_mediafile_inherited_access_group_ids", "meeting_user_ids", "meeting_id", + "poll_ids", ], ) if len(group.get("meeting_user_ids", [])) and not self.is_meeting_to_be_deleted( group["meeting_id"] ): raise ActionException("You cannot delete a group with users.") + if poll_ids := group.get("poll_ids", []): + running_polls = self.datastore.filter( + "poll", + And( + FilterOperator("state", "=", Poll.STATE_STARTED), + Or(FilterOperator("id", "=", poll_id) for poll_id in poll_ids), + ), + ["id"], + ) + if running_polls: + raise ActionException("You cannot delete a group with a running poll.") self.meeting_mediafile_ids: list[int] = list( set(group.get("meeting_mediafile_access_group_ids", [])) | set(group.get("meeting_mediafile_inherited_access_group_ids", [])) diff --git a/openslides_backend/action/actions/meeting/create.py b/openslides_backend/action/actions/meeting/create.py index 03f3b8f706..e8107fd6c3 100644 --- a/openslides_backend/action/actions/meeting/create.py +++ b/openslides_backend/action/actions/meeting/create.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from typing import Any -from openslides_backend.models.models import Meeting +from openslides_backend.models.models import Meeting, MeetingPollDefault from ....i18n.translator import Translator from ....i18n.translator import translate as _ @@ -17,6 +17,7 @@ from ...util.default_schema import DefaultSchema from ...util.register import register_action from ..group.create import GroupCreate +from ..meeting_poll_default.create import MeetingPollDefaultCreate from ..meeting_user.create import MeetingUserCreate from ..motion_workflow.create import ( MotionWorkflowCreateComplexWorkflowAction, @@ -171,11 +172,17 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: instance["default_group_id"] = id_from_fqid(fqid_default_group) instance["admin_group_id"] = id_from_fqid(fqid_admin_group) - instance["assignment_poll_default_group_ids"] = [ - id_from_fqid(fqid_delegates_group) - ] - instance["motion_poll_default_group_ids"] = [id_from_fqid(fqid_delegates_group)] - instance["topic_poll_default_group_ids"] = [id_from_fqid(fqid_delegates_group)] + self.execute_other_action( + MeetingPollDefaultCreate, + [ + { + "meeting_id": instance["id"], + poll_type_field: instance["id"], + "group_ids": [id_from_fqid(fqid_delegates_group)], + } + for poll_type_field in MeetingPollDefault.POLL_TYPE_FIELDS + ], + ) # Add user to admin group if admin_ids := instance.pop("admin_ids", []): diff --git a/openslides_backend/action/actions/meeting/update.py b/openslides_backend/action/actions/meeting/update.py index 1c450d65bb..399ef7a949 100644 --- a/openslides_backend/action/actions/meeting/update.py +++ b/openslides_backend/action/actions/meeting/update.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import datetime, timezone from typing import Any, cast @@ -5,6 +6,7 @@ from ....i18n.translator import Translator from ....i18n.translator import translate as _ +from ....models.mixins import POLL_TYPES from ....models.models import Meeting from ....permissions.management_levels import OrganizationManagementLevel from ....permissions.permission_helper import ( @@ -24,6 +26,9 @@ from ...util.default_schema import DefaultSchema from ...util.register import register_action from ..group.create import GroupCreate +from ..meeting_poll_default.create import MeetingPollDefaultCreate +from ..meeting_poll_default.helper_mixin import meeting_poll_default_schema +from ..meeting_poll_default.update import MeetingPollDefaultUpdate from .mixins import GetMeetingIdFromIdMixin, MeetingCheckTimesMixin meeting_settings_keys = [ @@ -128,15 +133,8 @@ "motions_export_follow_recommendation", "motions_enable_restricted_editor_for_manager", "motions_enable_restricted_editor_for_non_manager", - "motion_poll_ballot_paper_selection", - "motion_poll_ballot_paper_number", - "motion_poll_default_type", - "motion_poll_default_method", - "motion_poll_default_onehundred_percent_base", - "motion_poll_default_group_ids", - "motion_poll_default_backend", - "motion_poll_projection_name_order_first", - "motion_poll_projection_max_columns", + "poll_projection_name_order_first", + "poll_projection_max_columns", "users_enable_presence_view", "users_enable_vote_weight", "users_enable_vote_delegations", @@ -156,21 +154,21 @@ "users_forbid_delegator_to_vote", "assignments_export_title", "assignments_export_preamble", - "assignment_poll_ballot_paper_selection", - "assignment_poll_ballot_paper_number", + "poll_enable_max_votes_per_option", + "poll_default_live_voting_enabled", + "poll_default_allow_invalid", + "poll_default_allow_vote_split", "assignment_poll_add_candidates_to_list_of_speakers", - "assignment_poll_enable_max_votes_per_option", - "assignment_poll_sort_poll_result_by_votes", - "assignment_poll_default_type", "assignment_poll_default_method", - "assignment_poll_default_onehundred_percent_base", - "assignment_poll_default_group_ids", - "assignment_poll_default_backend", - "topic_poll_default_group_ids", - "poll_default_backend", - "poll_default_live_voting_enabled", + "topic_poll_default_method", ] +meeting_poll_default_fields = { + f"{poll_type}_poll_default_{field_name}": type_ + for poll_type in POLL_TYPES + for field_name, type_ in meeting_poll_default_schema.items() +} + @register_action("meeting.update") class MeetingUpdate( @@ -199,6 +197,7 @@ class MeetingUpdate( ], additional_optional_fields={ "set_as_template": {"type": "boolean"}, + **meeting_poll_default_fields, }, ) check_email_field = "users_email_replyto" @@ -334,6 +333,46 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: if (translations := instance.get("custom_translations")) is not None: instance["custom_translations"] = Jsonb(translations) + # Set poll defaults + poll_default_data: dict[str, dict[str, Any]] = defaultdict(dict) + for field in meeting_poll_default_fields.keys(): + if (value := instance.pop(field, None)) is not None: + poll_type, field_name = field.split("_poll_default_") + poll_default_data[poll_type][field_name] = value + + if poll_default_data: + poll_types_to_poll_defaults_fields: dict[str, str] = { + poll_type: f"{poll_type}_poll_config_id" + for poll_type in poll_default_data.keys() + } + db_poll_defaults = self.datastore.get( + fqid_from_collection_and_id(self.model.collection, instance["id"]), + list(poll_types_to_poll_defaults_fields.values()), + lock_result=False, + ) + action_data_create = [] + action_data_update = [] + + for poll_type, data in poll_default_data.items(): + if existing_id := db_poll_defaults.get( + poll_types_to_poll_defaults_fields[poll_type] + ): + action_data_update.append({"id": existing_id, **data}) + else: + action_data_create.append( + { + "meeting_id": instance["id"], + f"used_as_{poll_type}_poll_config_in_meeting_id": instance[ + "id" + ], + **data, + } + ) + if action_data_create: + self.execute_other_action(MeetingPollDefaultCreate, action_data_create) + if action_data_update: + self.execute_other_action(MeetingPollDefaultUpdate, action_data_update) + instance = super().update_instance(instance) return instance diff --git a/openslides_backend/action/actions/meeting_poll_default/__init__.py b/openslides_backend/action/actions/meeting_poll_default/__init__.py new file mode 100644 index 0000000000..26e86a804e --- /dev/null +++ b/openslides_backend/action/actions/meeting_poll_default/__init__.py @@ -0,0 +1 @@ +from . import create, delete, update # noqa diff --git a/openslides_backend/action/actions/meeting_poll_default/create.py b/openslides_backend/action/actions/meeting_poll_default/create.py new file mode 100644 index 0000000000..6348d28ca5 --- /dev/null +++ b/openslides_backend/action/actions/meeting_poll_default/create.py @@ -0,0 +1,118 @@ +from typing import Any + +from openslides_backend.shared.exceptions import ActionException + +from ....models.models import MeetingPollDefault, Poll +from ....shared.filters import FilterOperator +from ...generics.create import CreateAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("meeting_poll_default.create", action_type=ActionType.BACKEND_INTERNAL) +class MeetingPollDefaultCreate(CreateAction): + """ + Action to create a meeting_poll_default. + """ + + model = MeetingPollDefault() + schema = DefaultSchema(MeetingPollDefault()).get_create_schema( + required_properties=["meeting_id"], + optional_properties=[ + "used_as_assignment_poll_config_in_meeting_id", + "used_as_motion_poll_config_in_meeting_id", + "used_as_topic_poll_config_in_meeting_id", + "allow_abstain", + "allow_nota", + "display_chart", + "group_ids", + "onehundred_percent_base", + "sort_result_by_votes", + "strike_out", + "visibility", + ], + ) + + def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: + poll_types_fields = { + field_name: value + for field_name in MeetingPollDefault.POLL_TYPE_FIELDS + if (value := instance.get(field_name)) + } + self.check_exactly_one_of(instance, poll_types_fields) + + # Checks below are reachable only if exactly one poll type field is set + poll_type_field_name, poll_type_field_value = next( + iter(poll_types_fields.items()) + ) + self.check_equal_fields(instance, poll_type_field_name, poll_type_field_value) + self.check_prevent_updates(instance, poll_type_field_name) + + if "visibility" not in instance: + instance["visibility"] = ( + Poll.VISIBILITY_MANUALLY + if poll_type_field_name == "used_as_topic_poll_config_in_meeting_id" + else Poll.VISIBILITY_SECRET + ) + + if ( + poll_type_field_name == "used_as_topic_poll_config_in_meeting_id" + and "display_chart" not in instance + ): + instance["display_chart"] = "pie" + + return super().update_instance(instance) + + def check_exactly_one_of( + self, instance: dict[str, Any], poll_types_fields: dict[str, int] + ) -> None: + """ + Checks that exactly one of the poll type fields is defined for the instance: + - used_as_assignment_poll_config_in_meeting_id + - used_as_motion_poll_config_in_meeting_id + - used_as_topic_poll_config_in_meeting_id + """ + # TODO: replace with exactly_one_of constraint (https://github.com/OpenSlides/openslides-meta/issues/540) + if not len(poll_types_fields): + raise ActionException( + f"One of the fields {MeetingPollDefault.POLL_TYPE_FIELDS} must be set." + ) + if len(poll_types_fields) > 1: + raise ActionException( + f"Only one of {MeetingPollDefault.POLL_TYPE_FIELDS} can be set." + ) + + def check_equal_fields( + self, + instance: dict[str, Any], + poll_type_field_name: str, + poll_type_field_value: int, + ) -> None: + """ + Checks that value in poll type field matches the meeting_id. + """ + # TODO: replace with the updated equal_fields constraint (https://github.com/OpenSlides/openslides-meta/issues/541) + if poll_type_field_value != instance["meeting_id"]: + raise ActionException( + f"Values in fields '{poll_type_field_name}' and 'meeting_id' don't match." + ) + + def check_prevent_updates( + self, instance: dict[str, Any], poll_type_field_name: str + ) -> None: + """ + Prevents creating a new meeting_poll_default item if meeting already + contains meeting_poll_default for the same poll type. + """ + # TODO: replace with the updated prevent_updates constraint (https://github.com/OpenSlides/openslides-meta/issues/542) + meeting_id = instance["meeting_id"] + if self.datastore.filter( + "meeting_poll_default", + FilterOperator(poll_type_field_name, "=", meeting_id), + ["id"], + lock_result=False, + ): + raise ActionException( + f"'{poll_type_field_name}' already exists in meeting/{meeting_id}." + ) diff --git a/openslides_backend/action/actions/meeting_poll_default/delete.py b/openslides_backend/action/actions/meeting_poll_default/delete.py new file mode 100644 index 0000000000..ce2648bd1e --- /dev/null +++ b/openslides_backend/action/actions/meeting_poll_default/delete.py @@ -0,0 +1,15 @@ +from ....models.models import MeetingPollDefault +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("meeting_poll_default.delete", action_type=ActionType.BACKEND_INTERNAL) +class MeetingPollDefaultDelete(DeleteAction): + """ + Action to delete a meeting_poll_default. + """ + + model = MeetingPollDefault() + schema = DefaultSchema(MeetingPollDefault()).get_delete_schema() diff --git a/openslides_backend/action/actions/meeting_poll_default/helper_mixin.py b/openslides_backend/action/actions/meeting_poll_default/helper_mixin.py new file mode 100644 index 0000000000..1cadf2e2be --- /dev/null +++ b/openslides_backend/action/actions/meeting_poll_default/helper_mixin.py @@ -0,0 +1,16 @@ +from ....shared.typing import Schema + +meeting_poll_default_schema: Schema = { + "allow_abstain": {"type": "boolean"}, + "allow_nota": {"type": "boolean"}, + "display_chart": {"type": "string"}, + "group_ids": { + "type": "array", + "items": {"type": "integer"}, + "uniqueItems": True, + }, + "onehundred_percent_base": {"type": "string"}, + "sort_result_by_votes": {"type": "boolean"}, + "strike_out": {"type": "boolean"}, + "visibility": {"type": "string"}, +} diff --git a/openslides_backend/action/actions/meeting_poll_default/update.py b/openslides_backend/action/actions/meeting_poll_default/update.py new file mode 100644 index 0000000000..6801b9e92a --- /dev/null +++ b/openslides_backend/action/actions/meeting_poll_default/update.py @@ -0,0 +1,26 @@ +from ....models.models import MeetingPollDefault +from ...generics.update import UpdateAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("meeting_poll_default.update", action_type=ActionType.BACKEND_INTERNAL) +class MeetingPollDefaultUpdate(UpdateAction): + """ + Action to update a meeting_poll_default. + """ + + model = MeetingPollDefault() + schema = DefaultSchema(MeetingPollDefault()).get_update_schema( + optional_properties=[ + "allow_abstain", + "allow_nota", + "display_chart", + "group_ids", + "onehundred_percent_base", + "sort_result_by_votes", + "strike_out", + "visibility", + ] + ) diff --git a/openslides_backend/action/actions/meeting_user/base_delete.py b/openslides_backend/action/actions/meeting_user/base_delete.py index 171deb21c0..9f39d9677e 100644 --- a/openslides_backend/action/actions/meeting_user/base_delete.py +++ b/openslides_backend/action/actions/meeting_user/base_delete.py @@ -1,7 +1,9 @@ +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation from ....models.models import MeetingUser +from ....services.database.commands import GetManyRequest from ...generics.delete import DeleteAction from ...util.default_schema import DefaultSchema @@ -15,11 +17,35 @@ class MeetingUserBaseDelete(DeleteAction): schema = DefaultSchema(MeetingUser()).get_delete_schema() def get_history_information(self) -> HistoryInformation | None: - users = self.get_instances_with_fields(["user_id", "meeting_id"]) - return { - fqid_from_collection_and_id("user", user["user_id"]): [ - "Participant removed from meeting {}", - fqid_from_collection_and_id("meeting", user["meeting_id"]), - ] - for user in users - } + information: HistoryInformation = {} + meeting_users = self.get_instances_with_fields(["user_id", "meeting_id"]) + users = self.datastore.get_many( + [ + GetManyRequest( + "user", + [mu["user_id"] for mu in meeting_users], + ["is_present_in_meeting_ids"], + ) + ], + lock_result=False, + use_changed_models=False, + )["user"] + + for meeting_user in meeting_users: + information[ + fqid_from_collection_and_id("user", meeting_user["user_id"]) + ] = build_history_information_data( + [ + "Participant removed from meeting {}", + fqid_from_collection_and_id("meeting", meeting_user["meeting_id"]), + ], + ) + if meeting_user["meeting_id"] in users.get(meeting_user["user_id"], {}).get( + "is_present_in_meeting_ids", [] + ): + information[ + fqid_from_collection_and_id("meeting_user", meeting_user["id"]) + ] = build_history_information_data( + structured_information={"is_present": False} + ) + return information diff --git a/openslides_backend/action/actions/meeting_user/create.py b/openslides_backend/action/actions/meeting_user/create.py index f71b2edf6c..07168f1471 100644 --- a/openslides_backend/action/actions/meeting_user/create.py +++ b/openslides_backend/action/actions/meeting_user/create.py @@ -1,6 +1,7 @@ from typing import Any from openslides_backend.shared.exceptions import ActionException +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation @@ -51,9 +52,9 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: return super().update_instance(instance) def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} for instance in self.instances: - instance_information = [] + entries = [] fqids_per_collection = { collection_name: [ fqid_from_collection_and_id( @@ -65,15 +66,17 @@ def get_history_information(self) -> HistoryInformation | None: for collection_name in ["group", "structure_level"] if (ids := instance.get(f"{collection_name}_ids")) } - instance_information.append( + entries.append( self.compose_history_string(list(fqids_per_collection.items())) ) for collection_name, fqids in fqids_per_collection.items(): - instance_information.extend(fqids) - instance_information.append( + entries.extend(fqids) + entries.append( fqid_from_collection_and_id("meeting", instance["meeting_id"]), ) - information[fqid_from_collection_and_id("user", instance["user_id"])] = ( - instance_information + update_history_information( + information, + fqid_from_collection_and_id("user", instance["user_id"]), + entries, ) return information diff --git a/openslides_backend/action/actions/meeting_user/history_mixin.py b/openslides_backend/action/actions/meeting_user/history_mixin.py index 76a33e3263..260599b13b 100644 --- a/openslides_backend/action/actions/meeting_user/history_mixin.py +++ b/openslides_backend/action/actions/meeting_user/history_mixin.py @@ -1,16 +1,26 @@ from collections.abc import Iterable from copy import deepcopy -from typing import Any +from typing import Any, NotRequired, TypedDict from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin +from openslides_backend.shared.filters import FilterOperator +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.interfaces.event import Event, EventType from ....services.database.interface import GetManyRequest -from ....shared.patterns import fqid_from_collection_and_id +from ....shared.patterns import Field, FullQualifiedId, fqid_from_collection_and_id from ....shared.typing import HistoryInformation from ...action import Action +class ActionHistoryInformationData(TypedDict): + entries: NotRequired[list[tuple[str, ...]]] + structured_information: NotRequired[dict[Field, Any]] + + +ActionHistoryInformation = dict[FullQualifiedId, ActionHistoryInformationData] + + class MeetingUserHistoryMixin(ExtendHistoryMixin, Action): extend_history_to = "user_id" @@ -76,7 +86,7 @@ def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: ) def get_history_information(self) -> HistoryInformation | None: - information: dict[str, list[tuple[str, ...]]] = {} + information: ActionHistoryInformation = {} # Scan the instances and collect the info for the history information # Copy instances first since they are modified @@ -97,7 +107,10 @@ def get_history_information(self) -> HistoryInformation | None: ) return { - fqid: [string for entry in history for string in entry] + fqid: build_history_information_data( + [string for entry in history.get("entries", []) for string in entry], + history.get("structured_information", {}), + ) for fqid, history in information.items() } @@ -105,9 +118,10 @@ def add_updated_meeting_user_history_information( self, instance: dict[str, Any], db_instance: dict[str, Any], - information: dict[str, list[tuple[str, ...]]], + information: ActionHistoryInformation, ) -> None: - instance_information: list[tuple[str, ...]] = [] + user_entries: list[tuple[str, ...]] = [] + meeting_user_structured_information: dict[str, dict[str, list[int]]] = {} user_id = db_instance["user_id"] meeting_id = db_instance["meeting_id"] @@ -120,25 +134,36 @@ def add_updated_meeting_user_history_information( # meeting specific data update_fields = ["structure_level_ids", "number", "vote_weight"] if any(field in instance for field in update_fields): - instance_information.append( + user_entries.append( ( "Participant data updated in meeting {}", fqid_from_collection_and_id("meeting", meeting_id), ) ) - self.handle_group_updates(instance_information, instance, db_instance) - self.handle_delegations( - information, instance_information, instance, db_instance + self.handle_group_updates( + user_entries, + meeting_user_structured_information, + instance, + db_instance, ) + self.handle_delegations(information, user_entries, instance, db_instance) - if instance_information: + if user_entries: self.add_entries_to_history_information( - information, instance_information, for_user_id=user_id + information, + user_entries, + for_user_id=user_id, ) + if meeting_user_structured_information: + information[ + fqid_from_collection_and_id("meeting_user", db_instance["id"]) + ] = {"structured_information": meeting_user_structured_information} def add_created_meeting_user_history_information( - self, instance: dict[str, Any], information: dict[str, list[tuple[str, ...]]] + self, + instance: dict[str, Any], + information: ActionHistoryInformation, ) -> None: db_instance = self.datastore.get( fqid_from_collection_and_id(self.model.collection, instance["id"]), @@ -175,15 +200,32 @@ def add_created_meeting_user_history_information( if instance_information: self.add_entries_to_history_information( - information, instance_information, for_user_id=db_instance["user_id"] + information, + instance_information, + for_user_id=db_instance["user_id"], ) + if "group_ids" in instance: + structured_information: dict[str, dict[str, list[int]] | bool] = { + "group_ids": {"added": instance["group_ids"]} + } + is_present_in_meeting_ids = self.datastore.get( + fqid_from_collection_and_id("user", db_instance["user_id"]), + ["is_present_in_meeting_ids"], + lock_result=False, + ).get("is_present_in_meeting_ids", []) + if db_instance["meeting_id"] in is_present_in_meeting_ids: + structured_information["is_present"] = True + information[fqid_from_collection_and_id("meeting_user", instance["id"])] = { + "structured_information": structured_information + } def add_entries_to_history_information( self, - information: dict[str, list[tuple[str, ...]]], - entries: list[tuple[str, ...]], + information: ActionHistoryInformation, + entries: list[tuple[str, ...]] = [], for_user_id: int | None = None, for_meeting_user_id: int | None = None, + structured_information: dict[Field, Any] | None = None, ) -> None: if not for_user_id: if not for_meeting_user_id: @@ -197,11 +239,18 @@ def add_entries_to_history_information( user_id = for_user_id fqid = fqid_from_collection_and_id("user", user_id) if fqid not in information: - information[fqid] = entries + if entries: + information[fqid] = {"entries": entries} + if structured_information: + information[fqid]["structured_information"] = structured_information else: for entry in entries: - if entry not in information[fqid]: - information[fqid].append(entry) + if entry not in information[fqid]["entries"]: + information[fqid]["entries"].append(entry) + if structured_information: + information[fqid].setdefault("structured_information", dict()).update( + structured_information + ) def compose_history_string( self, fqids_per_collection: list[tuple[str, list[str]]] @@ -231,7 +280,8 @@ def compose_history_string( def handle_group_updates( self, - instance_information: list[tuple[str, ...]], + entries: list[tuple[str, ...]], + meeting_user_structured_information: dict[str, dict[str, list[int]]], instance: dict[str, Any], db_instance: dict[str, Any], ) -> None: @@ -242,6 +292,16 @@ def handle_group_updates( added = instance_group_ids - db_group_ids removed = db_group_ids - instance_group_ids + # Calculate history information for meeting_user + if added: + meeting_user_structured_information["group_ids"] = { + "added": list(added) + } + if removed: + meeting_user_structured_information.setdefault( + "group_ids", dict() + ).update({"removed": list(removed)}) + # remove default groups meeting = self.datastore.get( fqid_from_collection_and_id("meeting", meeting_id), @@ -252,6 +312,7 @@ def handle_group_updates( removed.discard(meeting.get("default_group_id")) changed = added | removed + # Calculate history information for user group_information: list[str] = [] if added and removed: group_information.append("Groups changed") @@ -273,11 +334,11 @@ def handle_group_updates( fqid_from_collection_and_id("meeting", meeting_id) ) if group_information: - instance_information.append(tuple(group_information)) + entries.append(tuple(group_information)) def handle_delegations( self, - information: dict[str, list[tuple[str, ...]]], + information: ActionHistoryInformation, instance_information: list[tuple[str, ...]], instance: dict[str, Any], db_instance: dict[str, Any], @@ -447,3 +508,22 @@ def handle_delegations( ], for_meeting_user_id=muser_id, ) + + def get_changed_group_ids( + self, user_id: int, update_data: dict[int, list[int]] = {} + ) -> list[int]: + db_groups: dict[int, dict[str, Any]] = self.datastore.filter( + "meeting_user", + FilterOperator("user_id", "=", user_id), + ["meeting_id", "group_ids"], + lock_result=False, + ) + changed_groups = [ + ( + updated_groups + if (updated_groups := update_data.get(db_data["meeting_id"])) + else db_data["group_ids"] + ) + for db_data in db_groups.values() + ] + return [id_ for group_ids in changed_groups for id_ in group_ids] diff --git a/openslides_backend/action/actions/meeting_user/update.py b/openslides_backend/action/actions/meeting_user/update.py index 7b59420094..deb59b733b 100644 --- a/openslides_backend/action/actions/meeting_user/update.py +++ b/openslides_backend/action/actions/meeting_user/update.py @@ -28,11 +28,14 @@ class MeetingUserUpdate( merge_fields = [ "assignment_candidate_ids", + "history_entry_ids", "motion_working_group_speaker_ids", "motion_editor_ids", "motion_supporter_ids", "motion_submitter_ids", "chat_message_ids", + "acting_ballot_ids", + "represented_ballot_ids", ] model = MeetingUser() @@ -40,6 +43,7 @@ class MeetingUserUpdate( optional_properties=[ "about_me", "group_ids", + "poll_option_ids", *meeting_user_standard_fields, *merge_fields, ], diff --git a/openslides_backend/action/actions/motion/base_create_forwarded.py b/openslides_backend/action/actions/motion/base_create_forwarded.py index c5a808bad7..6d675e7c0d 100644 --- a/openslides_backend/action/actions/motion/base_create_forwarded.py +++ b/openslides_backend/action/actions/motion/base_create_forwarded.py @@ -6,6 +6,7 @@ from psycopg.types.json import Jsonb from openslides_backend.action.actions.motion.mixins import TextHashMixin +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from ....i18n.translator import Translator @@ -641,22 +642,23 @@ def forward_mediafiles( return instance def get_history_information(self) -> HistoryInformation | None: - forwarded_entries = defaultdict(list) + information: HistoryInformation = {} for instance in self.instances: - forwarded_entries[ - fqid_from_collection_and_id("motion", instance["origin_id"]) - ].extend( + update_history_information( + information, + fqid_from_collection_and_id("motion", instance["origin_id"]), [ "Forwarded to {}", fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] + ], ) - return forwarded_entries | { - fqid_from_collection_and_id("motion", instance["id"]): [ - "Motion created (forwarded)" - ] - for instance in self.instances - } + for instance in self.instances: + update_history_information( + information, + fqid_from_collection_and_id("motion", instance["id"]), + ["Motion created (forwarded)"], + ) + return information def check_can_forward_with_attachments(self) -> None: organization = self.datastore.get( diff --git a/openslides_backend/action/actions/motion/delete.py b/openslides_backend/action/actions/motion/delete.py index fd1f9883c5..0b8e18d7fd 100644 --- a/openslides_backend/action/actions/motion/delete.py +++ b/openslides_backend/action/actions/motion/delete.py @@ -2,6 +2,10 @@ from typing import Any from openslides_backend.action.action import merge_history_informations +from openslides_backend.shared.history_events import ( + build_history_information_data, + update_history_information_multi, +) from openslides_backend.shared.typing import HistoryInformation from ....models.models import Motion @@ -76,14 +80,16 @@ def get_history_information(self) -> HistoryInformation | None: if self.history_information is None: return information # generate the history informations for the deleted amendments - fqids = [ + if not information: + information = {} + all_fqids = [ fqid_from_collection_and_id("motion", id_) for id_ in self.all_motion_ids ] - if not information: - information = {fqid: [self.history_information] for fqid in fqids} - else: - for fqid in fqids: - information[fqid] = [self.history_information] + update_history_information_multi( + information, + [fqid for fqid in all_fqids if fqid not in information], + [self.history_information], + ) return information def get_full_history_information(self) -> HistoryInformation | None: @@ -97,12 +103,20 @@ def get_full_history_information(self) -> HistoryInformation | None: return merge_history_informations( information or {}, { - fqid_from_collection_and_id("motion", id): ["Forwarded motion deleted"] + fqid_from_collection_and_id( + "motion", id + ): build_history_information_data( + ["Forwarded motion deleted"], + ) for instance in instances for id in instance.get("all_origin_ids", []) }, { - fqid_from_collection_and_id("motion", id): ["Origin motion deleted"] + fqid_from_collection_and_id( + "motion", id + ): build_history_information_data( + ["Origin motion deleted"], + ) for instance in instances for id in instance.get("all_derived_motion_ids", []) }, diff --git a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py index 11ea727bcb..5273101619 100644 --- a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py +++ b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py @@ -1,3 +1,4 @@ +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation @@ -9,9 +10,15 @@ def _get_state_history_information( self, instance_field: str, verbose_model: str ) -> HistoryInformation: return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): [ - verbose_model + " set to {}", - fqid_from_collection_and_id("motion_state", instance[instance_field]), - ] + fqid_from_collection_and_id( + self.model.collection, instance["id"] + ): build_history_information_data( + [ + verbose_model + " set to {}", + fqid_from_collection_and_id( + "motion_state", instance[instance_field] + ), + ], + ) for instance in self.instances } diff --git a/openslides_backend/action/actions/motion/update.py b/openslides_backend/action/actions/motion/update.py index b5884a759a..27bde7caa2 100644 --- a/openslides_backend/action/actions/motion/update.py +++ b/openslides_backend/action/actions/motion/update.py @@ -4,6 +4,7 @@ from psycopg.types.json import Jsonb +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from ....models.models import Motion @@ -223,19 +224,17 @@ def check_permissions(self, instance: dict[str, Any]) -> None: raise PermissionDenied(msg) def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} for instance in deepcopy(self.instances): - instance_information = [] + entries = [] # workflow timestamp changed if "workflow_timestamp" in instance: timestamp = instance.pop("workflow_timestamp") - instance_information.extend( - ["Workflow_timestamp set to {}", f"{timestamp}"] - ) + entries.extend(["Workflow_timestamp set to {}", f"{timestamp}"]) # category changed - instance_information.extend( + entries.extend( self.create_history_information_for_field( instance, "category_id", @@ -245,7 +244,7 @@ def get_history_information(self) -> HistoryInformation | None: ) # block changed - instance_information.extend( + entries.extend( self.create_history_information_for_field( instance, "block_id", "motion_block", "Motion block" ) @@ -263,12 +262,14 @@ def get_history_information(self) -> HistoryInformation | None: ] if any(field in instance for field in generic_update_fields): # still other fields given, so we also add the generic "updated" message - instance_information.append("Motion updated") + entries.append("Motion updated") - if instance_information: - information[ - fqid_from_collection_and_id(self.model.collection, instance["id"]) - ] = instance_information + if entries: + update_history_information( + information, + fqid_from_collection_and_id(self.model.collection, instance["id"]), + entries, + ) return information diff --git a/openslides_backend/action/actions/motion_comment/create_delete_update.py b/openslides_backend/action/actions/motion_comment/create_delete_update.py index eb23817b89..4141659144 100644 --- a/openslides_backend/action/actions/motion_comment/create_delete_update.py +++ b/openslides_backend/action/actions/motion_comment/create_delete_update.py @@ -2,6 +2,7 @@ from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin from openslides_backend.permissions.management_levels import OrganizationManagementLevel +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.typing import HistoryInformation from ....models.models import MotionComment @@ -103,12 +104,16 @@ def get_history_information(self) -> HistoryInformation | None: instances = self.get_instances_with_fields(["motion_id", "section_id"]) _, action = self.name.split(".") return { - fqid_from_collection_and_id("motion", instance["motion_id"]): [ - "Comment {} " + action + "d", - fqid_from_collection_and_id( - "motion_comment_section", instance["section_id"] - ), - ] + fqid_from_collection_and_id( + "motion", instance["motion_id"] + ): build_history_information_data( + [ + "Comment {} " + action + "d", + fqid_from_collection_and_id( + "motion_comment_section", instance["section_id"] + ), + ], + ) for instance in instances } diff --git a/openslides_backend/action/actions/option/__init__.py b/openslides_backend/action/actions/option/__init__.py deleted file mode 100644 index a27b3acfe9..0000000000 --- a/openslides_backend/action/actions/option/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import create, delete, set_auto_fields, update # noqa diff --git a/openslides_backend/action/actions/option/create.py b/openslides_backend/action/actions/option/create.py deleted file mode 100644 index 854f75768e..0000000000 --- a/openslides_backend/action/actions/option/create.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import Any - -from openslides_backend.shared.schema import id_list_schema - -from ....models.models import Option -from ....shared.exceptions import ActionException -from ...generics.create import CreateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ..poll_candidate_list.create import PollCandidateListCreate -from ..vote.create import VoteCreate -from ..vote.user_token_helper import get_user_token - - -@register_action("option.create", action_type=ActionType.BACKEND_INTERNAL) -class OptionCreateAction(CreateAction): - """ - (internal) Action to create an option - """ - - model = Option() - schema = DefaultSchema(Option()).get_create_schema( - required_properties=["meeting_id"], - optional_properties=[ - "text", - "poll_id", - "used_as_global_option_in_poll_id", - "content_object_id", - "yes", - "no", - "abstain", - "weight", - ], - additional_optional_fields={"poll_candidate_user_ids": id_list_schema}, - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - keyword = self.check_one_of_three_keywords(instance) - action_data = [] - user_token = get_user_token() - yes_data = self.get_vote_action_data(instance, "Y", "yes", user_token) - if yes_data is not None: - action_data.append(yes_data) - no_data = self.get_vote_action_data(instance, "N", "no", user_token) - if no_data is not None: - action_data.append(no_data) - abstain_data = self.get_vote_action_data(instance, "A", "abstain", user_token) - if abstain_data is not None: - action_data.append(abstain_data) - if action_data: - self.apply_instance(instance) - self.execute_other_action(VoteCreate, action_data) - if keyword == "poll_candidate_user_ids": - self.apply_instance(instance) - user_ids = instance.pop("poll_candidate_user_ids") - self.execute_other_action( - PollCandidateListCreate, - [ - { - "option_id": instance["id"], - "meeting_id": instance["meeting_id"], - "entries": [ - {"user_id": user_id, "weight": i} - for i, user_id in enumerate(user_ids, start=1) - ], - } - ], - ) - - return instance - - def get_vote_action_data( - self, instance: dict[str, Any], value: str, prop: str, user_token: str - ) -> dict[str, Any] | None: - if instance.get(prop): - return { - "value": value, - "weight": instance[prop], - "option_id": instance["id"], - "user_token": user_token, - } - return None - - @staticmethod - def check_one_of_three_keywords(instance: dict[str, Any]) -> str: - keys = [ - key - for key in ("text", "content_object_id", "poll_candidate_user_ids") - if key in instance - ] - if len(keys) != 1: - raise ActionException( - "Need one of text, content_object_id or poll_candidate_user_ids." - ) - return keys[0] diff --git a/openslides_backend/action/actions/option/delete.py b/openslides_backend/action/actions/option/delete.py deleted file mode 100644 index b2bc679e67..0000000000 --- a/openslides_backend/action/actions/option/delete.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any - -from ....models.models import Option -from ....shared.patterns import ( - collection_from_fqid, - fqid_from_collection_and_id, - id_from_fqid, -) -from ...generics.delete import DeleteAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ..poll_candidate_list.delete import PollCandidateListDelete - - -@register_action("option.delete", action_type=ActionType.BACKEND_INTERNAL) -class OptionDelete(DeleteAction): - """ - Action to delete options. - """ - - model = Option() - schema = DefaultSchema(Option()).get_delete_schema() - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - option = self.datastore.get( - fqid_from_collection_and_id("option", instance["id"]), ["content_object_id"] - ) - if ( - option.get("content_object_id") - and collection_from_fqid(option["content_object_id"]) - == "poll_candidate_list" - ): - self.execute_other_action( - PollCandidateListDelete, - [{"id": id_from_fqid(option["content_object_id"])}], - ) - return instance diff --git a/openslides_backend/action/actions/option/set_auto_fields.py b/openslides_backend/action/actions/option/set_auto_fields.py deleted file mode 100644 index f297d50e70..0000000000 --- a/openslides_backend/action/actions/option/set_auto_fields.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any - -from ....models.models import Option -from ...generics.update import UpdateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("option.set_auto_fields", action_type=ActionType.BACKEND_INTERNAL) -class OptionSetAutoFields(UpdateAction): - """ - Action to calculate auto fields for options (yes, no, abstain) - """ - - model = Option() - schema = DefaultSchema(Option()).get_update_schema( - optional_properties=["yes", "no", "abstain"] - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - set_without_calc = ( - instance.get("yes") or instance.get("no") or instance.get("abstain") - ) - if not set_without_calc: - # TODO in this case we should autogenerate the fields. - raise NotImplementedError() - return instance diff --git a/openslides_backend/action/actions/option/update.py b/openslides_backend/action/actions/option/update.py deleted file mode 100644 index 9c1225d8d0..0000000000 --- a/openslides_backend/action/actions/option/update.py +++ /dev/null @@ -1,185 +0,0 @@ -from typing import Any - -from ....models.models import Option, Poll -from ....services.database.commands import GetManyRequest -from ....shared.exceptions import ActionException -from ....shared.patterns import fqid_from_collection_and_id -from ....shared.schema import decimal_schema -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ..poll.functions import check_poll_or_option_perms -from ..poll.set_state import PollSetState -from ..vote.create import VoteCreate -from ..vote.update import VoteUpdate -from ..vote.user_token_helper import get_user_token - -option_keys = ("yes", "no", "abstain") -option_keys_map = {key[0].upper(): key for key in option_keys} - - -@register_action("option.update") -class OptionUpdateAction(UpdateAction): - """ - Action to update an option. - """ - - model = Option() - schema = DefaultSchema(Option()).get_update_schema( - additional_optional_fields={ - "Y": decimal_schema, - "N": decimal_schema, - "A": decimal_schema, - "publish_immediately": {"type": "boolean"}, - } - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - """Update votes and auto calculate yes, no, abstain.""" - - option, poll = self._get_option_and_poll(instance["id"]) - state_change = self.check_state_change(instance, poll) - - if option.get("used_as_global_option_in_poll_id"): - self._handle_global_option_data(instance, poll) - else: - self._handle_poll_option_data(instance, poll) - - id_to_vote = self._fetch_votes(option.get("vote_ids", [])) - - action_data_create = [] - action_data_update = [] - user_token = get_user_token() - - for letter, option_key in option_keys_map.items(): - if option_key in instance: - vote_id = self._get_vote_id(letter, id_to_vote) - if vote_id is None: - action_data_create.append( - { - "option_id": instance["id"], - "value": letter, - "weight": instance[option_key], - "user_token": user_token, - } - ) - else: - action_data_update.append( - {"id": vote_id, "weight": instance[option_key]} - ) - if action_data_create: - self.execute_other_action(VoteCreate, action_data_create) - if action_data_update: - self.execute_other_action(VoteUpdate, action_data_update) - - execute_other_action = False - if state_change: - state = Poll.STATE_FINISHED - execute_other_action = True - if ( - execute_other_action - or ( - poll["state"] == Poll.STATE_FINISHED - and poll["type"] == Poll.TYPE_ANALOG - ) - ) and instance.get("publish_immediately"): - state = Poll.STATE_PUBLISHED - execute_other_action = True - if execute_other_action: - self.execute_other_action( - PollSetState, [{"id": poll["id"], "state": state}] - ) - - instance.pop("publish_immediately", None) - return instance - - def _get_option_and_poll( - self, option_id: int - ) -> tuple[dict[str, Any], dict[str, Any]]: - option = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, option_id), - ["poll_id", "used_as_global_option_in_poll_id", "vote_ids", "meeting_id"], - ) - return ( - option, - self.datastore.get( - fqid_from_collection_and_id("poll", option["poll_id"]), - [ - "id", - "state", - "type", - "pollmethod", - "global_yes", - "global_no", - "global_abstain", - "meeting_id", - "content_object_id", - ], - lock_result=["type"], - ), - ) - - def _handle_poll_option_data( - self, instance: dict[str, Any], poll: dict[str, Any] - ) -> None: - if poll.get("type") == "analog": - data = self._get_data(instance) - pollmethod = poll["pollmethod"] - for letter, key in option_keys_map.items(): - if letter in pollmethod: - instance[key] = data.get(key, "-2.000000") - elif data.get(key) is not None: - raise ActionException( - f"Pollmethod {pollmethod} does not support {key} votes" - ) - - def _handle_global_option_data( - self, instance: dict[str, Any], poll: dict[str, Any] - ) -> None: - if poll.get("type") == "analog": - data = self._get_data(instance) - for key in option_keys: - if poll.get(f"global_{key}") and poll.get("pollmethod") in ("Y", "N"): - instance[key] = data.get(key, "-2.000000") - elif key in data: - raise ActionException( - f"Global {key} votes are not allowed for this poll" - ) - - def _get_data(self, instance: dict[str, Any]) -> dict[str, Any]: - return { - key: instance.pop(letter) - for letter, key in option_keys_map.items() - if letter in instance - } - - def _fetch_votes(self, vote_ids: list[int]) -> dict[int, dict[str, Any]]: - get_many_request = GetManyRequest("vote", vote_ids, ["value"]) - gm_result = self.datastore.get_many([get_many_request]) - votes = gm_result.get("vote", {}) - return votes - - def _get_vote_id( - self, search_value: str, id_to_vote: dict[int, dict[str, Any]] - ) -> int | None: - for key, item in id_to_vote.items(): - if item["value"] == search_value: - return key - return None - - def check_state_change( - self, instance: dict[str, Any], poll: dict[str, Any] - ) -> bool: - return ( - poll.get("type") == Poll.TYPE_ANALOG - and poll.get("state") == Poll.STATE_CREATED - and any(letter in instance for letter in option_keys_map.keys()) - ) - - def check_permissions(self, instance: dict[str, Any]) -> None: - _, poll = self._get_option_and_poll(instance["id"]) - content_object_id = poll.get("content_object_id", "") - meeting_id = poll["meeting_id"] - check_poll_or_option_perms( - content_object_id, self.datastore, self.user_id, meeting_id - ) diff --git a/openslides_backend/action/actions/poll/__init__.py b/openslides_backend/action/actions/poll/__init__.py index 4a66fcc507..1fc925d0d8 100644 --- a/openslides_backend/action/actions/poll/__init__.py +++ b/openslides_backend/action/actions/poll/__init__.py @@ -1,11 +1 @@ -from . import ( # noqa - anonymize, - create, - delete, - publish, - reset, - set_state, - start, - stop, - update, -) +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll/anonymize.py b/openslides_backend/action/actions/poll/anonymize.py deleted file mode 100644 index 280d020e31..0000000000 --- a/openslides_backend/action/actions/poll/anonymize.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Any - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin - -from ....models.models import Poll -from ....services.database.commands import GetManyRequest -from ....shared.exceptions import ActionException -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ...util.typing import ActionData -from ..vote.anonymize import VoteAnonymize -from .mixins import PollHistoryMixin, PollPermissionMixin - - -@register_action("poll.anonymize") -class PollAnonymize( - ExtendHistoryMixin, UpdateAction, PollPermissionMixin, PollHistoryMixin -): - """ - Action to anonymize a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema() - poll_history_information = "anonymized" - extend_history_to = "content_object_id" - - def get_updated_instances(self, action_data: ActionData) -> ActionData: - for instance in action_data: - self.check_allowed(instance["id"]) - option_ids = self._get_option_ids(instance["id"]) - options = self._get_options(option_ids) - - for option_id in options: - option = options[option_id] - if option.get("vote_ids"): - self._remove_user_id_from(option["vote_ids"]) - - instance["is_pseudoanonymized"] = True - yield instance - - def check_allowed(self, poll_id: int) -> None: - poll = self.datastore.get( - fqid_from_collection_and_id("poll", poll_id), ["type", "state"] - ) - - if not poll.get("state") in (Poll.STATE_FINISHED, Poll.STATE_PUBLISHED): - raise ActionException( - "Anonymizing can only be done after finishing a poll." - ) - if poll.get("type") != Poll.TYPE_NAMED: - raise ActionException("You can only anonymize named polls.") - - def _get_option_ids(self, poll_id: int) -> list[int]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, poll_id), - ["option_ids", "global_option_id"], - ) - option_ids = poll.get("option_ids", []) - if poll.get("global_option_id"): - option_ids.append(poll["global_option_id"]) - return option_ids - - def _get_options(self, option_ids: list[int]) -> dict[int, dict[str, Any]]: - get_many_request = GetManyRequest("option", option_ids, ["vote_ids"]) - gm_result = self.datastore.get_many([get_many_request]) - options: dict[int, dict[str, Any]] = gm_result.get("option", {}) - return options - - def _remove_user_id_from(self, vote_ids: list[int]) -> None: - action_data = [] - for id_ in vote_ids: - action_data.append({"id": id_}) - self.execute_other_action(VoteAnonymize, action_data) diff --git a/openslides_backend/action/actions/poll/base.py b/openslides_backend/action/actions/poll/base.py deleted file mode 100644 index 62784f4d0f..0000000000 --- a/openslides_backend/action/actions/poll/base.py +++ /dev/null @@ -1,26 +0,0 @@ -from openslides_backend.models.models import Poll - -from ....shared.exceptions import ActionException - - -def base_check_onehundred_percent_base( - pollmethod: str | None, onehundred_percent_base: str | None -) -> None: - error_msg = "This onehundred_percent_base not allowed in this pollmethod." - if pollmethod == "Y" and onehundred_percent_base in ( - Poll.ONEHUNDRED_PERCENT_BASE_N, - Poll.ONEHUNDRED_PERCENT_BASE_YN, - Poll.ONEHUNDRED_PERCENT_BASE_YNA, - ): - raise ActionException(error_msg) - elif pollmethod == "N" and onehundred_percent_base in ( - Poll.ONEHUNDRED_PERCENT_BASE_Y, - Poll.ONEHUNDRED_PERCENT_BASE_YN, - Poll.ONEHUNDRED_PERCENT_BASE_YNA, - ): - raise ActionException(error_msg) - elif ( - pollmethod == "YN" - and onehundred_percent_base == Poll.ONEHUNDRED_PERCENT_BASE_YNA - ): - raise ActionException(error_msg) diff --git a/openslides_backend/action/actions/poll/create.py b/openslides_backend/action/actions/poll/create.py deleted file mode 100644 index 7f02240456..0000000000 --- a/openslides_backend/action/actions/poll/create.py +++ /dev/null @@ -1,265 +0,0 @@ -from typing import Any - -from openslides_backend.shared.util import ONE_ORGANIZATION_FQID - -from ....models.models import Poll -from ....shared.exceptions import ActionException -from ....shared.patterns import collection_from_fqid, fqid_from_collection_and_id -from ....shared.schema import decimal_schema, id_list_schema, optional_fqid_schema -from ...mixins.forbid_anonymous_group_mixin import ForbidAnonymousGroupMixin -from ...mixins.sequential_numbers_mixin import SequentialNumbersMixin -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ..option.create import OptionCreateAction -from .base import base_check_onehundred_percent_base -from .mixins import PollHistoryMixin, PollPermissionMixin, PollValidationMixin - -options_schema = { - "description": "A option inside a poll create schema", - "type": "object", - "properties": { - "text": {"type": "string", "description": "the text of an option"}, - "content_object_id": optional_fqid_schema, - "poll_candidate_user_ids": id_list_schema, - "Y": decimal_schema, - "N": decimal_schema, - "A": decimal_schema, - }, - "additionalProperties": False, -} - - -@register_action("poll.create") -class PollCreateAction( - PollValidationMixin, - SequentialNumbersMixin, - PollPermissionMixin, - PollHistoryMixin, - ForbidAnonymousGroupMixin, -): - """ - Action to create a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_create_schema( - required_properties=["title", "type", "pollmethod", "meeting_id"], - additional_required_fields={ - "options": { - "type": "array", - "items": options_schema, - "minItems": 1, - } - }, - optional_properties=[ - "content_object_id", - "description", - "min_votes_amount", - "max_votes_amount", - "max_votes_per_option", - "global_yes", - "global_no", - "global_abstain", - "onehundred_percent_base", - "votesvalid", - "votesinvalid", - "votescast", - "entitled_group_ids", - "backend", - "live_voting_enabled", - ], - additional_optional_fields={ - "publish_immediately": {"type": "boolean"}, - "amount_global_yes": decimal_schema, - "amount_global_no": decimal_schema, - "amount_global_abstain": decimal_schema, - }, - ) - poll_history_information = "created" - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - instance = super().update_instance(instance) - action_data = [] - - state_change = self.check_state_change(instance) - is_motion_poll = collection_from_fqid(instance["content_object_id"]) == "motion" - is_assignment_poll = ( - collection_from_fqid(instance["content_object_id"]) == "assignment" - ) - is_list_poll = "poll_candidate_user_ids" in instance.get("options", [{}])[0] - - # check enabled_electronic_voting - if instance["type"] in (Poll.TYPE_NAMED, Poll.TYPE_PSEUDOANONYMOUS): - organization = self.datastore.get( - ONE_ORGANIZATION_FQID, - ["enable_electronic_voting"], - ) - if not organization.get("enable_electronic_voting"): - raise ActionException("Electronic voting is not allowed.") - - # check named and live_voting_enabled - if instance.get("live_voting_enabled") and not ( - instance["type"] == Poll.TYPE_NAMED - and ( - is_motion_poll - or ( - is_assignment_poll - and ( - ( - not instance.get("global_yes") - and instance["pollmethod"] == "Y" - and instance.get("max_votes_amount") == 1 - ) - or is_list_poll - or ( - ( - instance["pollmethod"] == "YNA" - or instance["pollmethod"] == "YN" - ) - and len(instance.get("options", [])) == 1 - ) - ) - ) - ) - ): - raise ActionException( - "live_voting_enabled only allowed for named motion polls and some named assignment polls." - ) - - # check entitled_group_ids and analog - if instance["type"] == Poll.TYPE_ANALOG and "entitled_group_ids" in instance: - raise ActionException("entitled_group_ids is not allowed for analog.") - # check analog and onehundredpercentbase entitled - if instance["type"] == Poll.TYPE_ANALOG and ( - base := instance.get("onehundred_percent_base") - ) in ( - Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED, - Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED_PRESENT, - ): - raise ActionException( - f"onehundred_percent_base: value {base} is not allowed for analog." - ) - self.check_onehundred_percent_base(instance) - - # check non-analog and publish_immediately - if instance["type"] != Poll.TYPE_ANALOG and "publish_immediately" in instance: - raise ActionException("publish_immediately only allowed for analog polls.") - - # check content_object_id motion and state allow_create_poll - if is_motion_poll: - motion = self.datastore.get(instance["content_object_id"], ["state_id"]) - if not motion.get("state_id"): - raise ActionException("Motion doesn't have a state.") - state = self.datastore.get( - fqid_from_collection_and_id("motion_state", motion["state_id"]), - ["allow_create_poll"], - ) - if not state.get("allow_create_poll"): - raise ActionException("Motion state doesn't allow to create poll.") - - # handle non-global options - unique_set = set() - - for weight, option in enumerate(instance.get("options", []), start=1): - # check the keys with staticmethod from option.create, where they belong - key = OptionCreateAction.check_one_of_three_keywords(option) - data: dict[str, Any] = { - "poll_id": instance["id"], - "meeting_id": instance["meeting_id"], - "weight": weight, - key: option[key], - } - - o_obj = f"{key},{option[key]}" - if o_obj in unique_set: - raise ActionException( - f"Duplicated option in poll.options: {option[key]}" - ) - else: - unique_set.add(o_obj) - - if instance["type"] == "analog": - if instance["pollmethod"] == "N": - data["no"] = self.parse_vote_value(option, "N") - else: - data["yes"] = self.parse_vote_value(option, "Y") - if instance["pollmethod"] in ("YN", "YNA"): - data["no"] = self.parse_vote_value(option, "N") - if instance["pollmethod"] == "YNA": - data["abstain"] = self.parse_vote_value(option, "A") - - action_data.append(data) - - # handle global option - global_data = { - "text": "global option", - "used_as_global_option_in_poll_id": instance["id"], - "meeting_id": instance["meeting_id"], - "weight": 1, - } - if instance["type"] == "analog": - for option in ["yes", "no", "abstain"]: - if instance["type"] == "analog" and instance.get(f"global_{option}"): - global_data[option] = self.parse_vote_value( - instance, f"amount_global_{option}" - ) - instance.pop(f"amount_global_{option}", None) - action_data.append(global_data) - - # Execute the create option actions - self.apply_instance(instance) - self.execute_other_action( - OptionCreateAction, - action_data, - ) - - # set state - instance["state"] = Poll.STATE_CREATED - if state_change: - instance["state"] = Poll.STATE_FINISHED - if ( - instance["type"] == Poll.TYPE_ANALOG - and instance["state"] == Poll.STATE_FINISHED - and instance.get("publish_immediately") - ): - instance["state"] = Poll.STATE_PUBLISHED - - # set votescast, votesvalid, votesinvalid defaults - for field in ("votescast", "votesvalid", "votesinvalid"): - instance[field] = ( - self.parse_vote_value(instance, field) - if instance["type"] == Poll.TYPE_ANALOG - else instance.get(field, "0.000000") - ) - - # calculate is_pseudoanonymized - instance["is_pseudoanonymized"] = instance["type"] == Poll.TYPE_PSEUDOANONYMOUS - - instance.pop("options", None) - instance.pop("publish_immediately", None) - self.check_anonymous_not_in_list_fields(instance, ["entitled_group_ids"]) - return instance - - def parse_vote_value(self, data: dict[str, Any], field: str) -> Any: - return data.get(field, "-2.000000") - - def check_onehundred_percent_base(self, instance: dict[str, Any]) -> None: - pollmethod = instance["pollmethod"] - onehundred_percent_base = instance.get("onehundred_percent_base") - base_check_onehundred_percent_base(pollmethod, onehundred_percent_base) - - def check_state_change(self, instance: dict[str, Any]) -> bool: - if instance["type"] != Poll.TYPE_ANALOG: - return False - check_fields = ( - "votesvalid", - "votesinvalid", - "votescast", - ) - for field in check_fields: - if instance.get(field): - return True - for option in instance.get("options", []): - if option.get("Y") or option.get("N") or option.get("A"): - return True - return False diff --git a/openslides_backend/action/actions/poll/delete.py b/openslides_backend/action/actions/poll/delete.py index 127617011b..bfb4e42ff0 100644 --- a/openslides_backend/action/actions/poll/delete.py +++ b/openslides_backend/action/actions/poll/delete.py @@ -1,103 +1,15 @@ -from collections.abc import Callable - from ....models.models import Poll -from ....services.database.interface import GetManyRequest -from ....shared.exceptions import VoteServiceException from ...generics.delete import DeleteAction +from ...util.action_type import ActionType from ...util.default_schema import DefaultSchema from ...util.register import register_action -from ...util.typing import ActionData -from .mixins import PollHistoryMixin, PollPermissionMixin -@register_action("poll.delete") -class PollDelete(DeleteAction, PollPermissionMixin, PollHistoryMixin): +@register_action("poll.delete", action_type=ActionType.BACKEND_INTERNAL) +class PollDelete(DeleteAction): """ - Action to delete polls. + Action to delete a poll. """ model = Poll() schema = DefaultSchema(Poll()).get_delete_schema() - poll_history_information = "deleted" - - def prefetch(self, action_data: ActionData) -> None: - result = self.datastore.get_many( - [ - GetManyRequest( - "poll", - list({instance["id"] for instance in action_data}), - [ - "content_object_id", - "meeting_id", - "entitled_group_ids", - "voted_ids", - "option_ids", - "global_option_id", - "projection_ids", - "state", - ], - ), - ], - use_changed_models=False, - ) - polls = result["poll"].values() - self.started_polls = [ - id_ - for id_, poll in result["poll"].items() - if poll.get("state") == "started" - ] - meeting_ids = list({poll["meeting_id"] for poll in polls}) - group_ids = list( - { - group_id - for poll in polls - for group_id in poll.get("entitled_group_ids", ()) - } - ) - option_ids = [ - option_id - for poll in polls - if poll.get("option_ids") - for option_id in poll["option_ids"] - ] - requests = [ - GetManyRequest( - "meeting", - meeting_ids, - [ - "is_active_in_organization_id", - "name", - "option_ids", - "poll_ids", - ], - ), - GetManyRequest( - "option", - option_ids, - [ - "meeting_id", - "vote_ids", - "content_object_id", - "poll_id", - "used_as_global_option_in_poll_id", - "vote_ids", - ], - ), - GetManyRequest( - "group", - group_ids, - ["poll_ids"], - ), - ] - self.datastore.get_many(requests, use_changed_models=False) - - def get_on_success(self, action_data: ActionData) -> Callable[[], None]: - def on_success() -> None: - for instance in action_data: - if (id_ := instance["id"]) in self.started_polls: - try: - self.vote_service.clear(id_) - except VoteServiceException as e: - self.logger.error(f"Error clearing vote {id_}: {str(e)}") - - return on_success diff --git a/openslides_backend/action/actions/poll/functions.py b/openslides_backend/action/actions/poll/functions.py deleted file mode 100644 index 8899611043..0000000000 --- a/openslides_backend/action/actions/poll/functions.py +++ /dev/null @@ -1,25 +0,0 @@ -from ....permissions.permission_helper import has_perm -from ....permissions.permissions import Permission, Permissions -from ....services.database.interface import Database -from ....shared.exceptions import ActionException, MissingPermission -from ....shared.patterns import KEYSEPARATOR, collection_from_fqid - - -def check_poll_or_option_perms( - content_object_id: str, - datastore: Database, - user_id: int, - meeting_id: int, -) -> None: - if content_object_id.startswith("motion" + KEYSEPARATOR): - perm: Permission = Permissions.Motion.CAN_MANAGE_POLLS - elif content_object_id.startswith("assignment" + KEYSEPARATOR): - perm = Permissions.Assignment.CAN_MANAGE_POLLS - elif content_object_id.startswith("topic" + KEYSEPARATOR): - perm = Permissions.Poll.CAN_MANAGE - else: - raise ActionException( - f"'{collection_from_fqid(content_object_id)}' is not a valid poll collection." - ) - if not has_perm(datastore, user_id, perm, meeting_id): - raise MissingPermission(perm) diff --git a/openslides_backend/action/actions/poll/mixins.py b/openslides_backend/action/actions/poll/mixins.py deleted file mode 100644 index ad8623a74e..0000000000 --- a/openslides_backend/action/actions/poll/mixins.py +++ /dev/null @@ -1,270 +0,0 @@ -from collections import defaultdict -from decimal import Decimal -from typing import Any, cast - -from psycopg.types.json import Jsonb - -from openslides_backend.shared.typing import HistoryInformation - -from ....services.database.commands import GetManyRequest -from ....shared.exceptions import ActionException, VoteServiceException -from ....shared.patterns import collection_from_fqid, fqid_from_collection_and_id -from ...action import Action -from ..option.set_auto_fields import OptionSetAutoFields -from ..projector_countdown.mixins import CountdownCommand, CountdownControl -from ..vote.create import VoteCreate -from ..vote.user_token_helper import get_user_token -from .functions import check_poll_or_option_perms - - -class PollValidationMixin(Action): - def validate_instance(self, instance: dict[str, Any]) -> None: - super().validate_instance(instance) - - if poll_id := instance.get("id"): - poll = self.datastore.get( - fqid_from_collection_and_id("poll", poll_id), - ["max_votes_amount", "min_votes_amount", "max_votes_per_option"], - ) - max_votes_amount = cast( - int, - instance.get( - "max_votes_amount", poll["max_votes_amount"] if poll_id else 1 - ), - ) - min_votes_amount = cast( - int, - instance.get( - "min_votes_amount", poll["min_votes_amount"] if poll_id else 1 - ), - ) - max_votes_per_option = cast( - int, - instance.get( - "max_votes_per_option", poll["max_votes_per_option"] if poll_id else 1 - ), - ) - - if max_votes_amount < max_votes_per_option: - raise ActionException( - "The maximum votes per option cannot be higher than the maximum amount of votes in total." - ) - if max_votes_amount < min_votes_amount: - raise ActionException( - "The minimum amount of votes cannot be higher than the maximum amount of votes." - ) - - -class PollPermissionMixin(Action): - def check_permissions(self, instance: dict[str, Any]) -> None: - if "meeting_id" in instance: - content_object_id = instance.get("content_object_id", "") - meeting_id = instance["meeting_id"] - else: - poll = self.datastore.get( - fqid_from_collection_and_id("poll", instance["id"]), - ["content_object_id", "meeting_id"], - lock_result=False, - ) - content_object_id = poll.get("content_object_id", "") - meeting_id = poll["meeting_id"] - if not content_object_id: - raise ActionException("No 'content_object_id' was given") - check_poll_or_option_perms( - content_object_id, self.datastore, self.user_id, meeting_id - ) - - -class StopControl(CountdownControl, Action): - def on_stop(self, instance: dict[str, Any]) -> None: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - [ - "state", - "meeting_id", - "pollmethod", - "global_option_id", - "entitled_group_ids", - ], - lock_result=False, - ) - # reset countdown given by meeting - meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", poll["meeting_id"]), - [ - "poll_couple_countdown", - "poll_countdown_id", - "users_enable_vote_weight", - "users_enable_vote_delegations", - ], - lock_result=False, - ) - if meeting.get("poll_couple_countdown") and meeting.get("poll_countdown_id"): - self.control_countdown(meeting["poll_countdown_id"], CountdownCommand.RESET) - - # stop poll in vote service and create vote objects - results = self.vote_service.stop(instance["id"]) - action_data = [] - votesvalid = Decimal("0.000000") - option_results: dict[int, dict[str, Decimal]] = defaultdict( - lambda: defaultdict(lambda: Decimal("0.000000")) - ) # maps options to their respective YNA sums - for ballot in results["votes"]: - user_token = get_user_token() - vote_weight = Decimal(ballot["weight"]) - votesvalid += vote_weight - vote_template: dict[str, str | int] = {"user_token": user_token} - if "vote_user_id" in ballot: - vote_template["user_id"] = ballot["vote_user_id"] - if "request_user_id" in ballot: - vote_template["delegated_user_id"] = ballot["request_user_id"] - - if isinstance(ballot["value"], dict): - for option_id_str, value in ballot["value"].items(): - option_id = int(option_id_str) - - vote_value = value - vote_weighted = vote_weight # use new variable vote_weighted because pollmethod=Y/N does not imply anymore that only one loop is done (see max_votes_per_option) - if poll["pollmethod"] in ("Y", "N"): - if value == 0: - continue - vote_value = poll["pollmethod"] - vote_weighted *= value - - option_results[option_id][vote_value] += vote_weighted - action_data.append( - { - "value": vote_value, - "option_id": option_id, - "weight": str(vote_weighted), - **vote_template, - } - ) - elif isinstance(ballot["value"], str): - vote_value = ballot["value"] - option_id = poll["global_option_id"] - option_results[option_id][vote_value] += vote_weight - action_data.append( - { - "value": vote_value, - "option_id": option_id, - "weight": str(vote_weight), - **vote_template, - } - ) - else: - raise VoteServiceException("Invalid response from vote service") - self.execute_other_action(VoteCreate, action_data) - # update results into option - self.execute_other_action( - OptionSetAutoFields, - [ - { - "id": _id, - "yes": str(option["Y"]), - "no": str(option["N"]), - "abstain": str(option["A"]), - } - for _id, option in option_results.items() - ], - ) - # set voted ids - voted_ids = results["user_ids"] - instance["voted_ids"] = voted_ids - - # set votescast, votesvalid, votesinvalid - instance["votesvalid"] = str(votesvalid) - instance["votescast"] = str(Decimal("0.000000") + Decimal(len(voted_ids))) - instance["votesinvalid"] = "0.000000" - - # set entitled users at stop. - instance["entitled_users_at_stop"] = Jsonb( - self.get_entitled_users(poll | instance, meeting) - ) - - def get_entitled_users( - self, poll: dict[str, Any], meeting: dict[str, Any] - ) -> list[dict[str, Any]]: - entitled_users = [] - all_voted_users = set(poll.get("voted_ids", [])) - - # get all users from the groups. - gmr = GetManyRequest( - "group", poll.get("entitled_group_ids", []), ["meeting_user_ids"] - ) - gm_result = self.datastore.get_many( - [gmr], - lock_result=False, - ) - groups = gm_result.get("group", {}).values() - - # fetch presence status - meeting_user_ids = set() - for group in groups: - meeting_user_ids.update(group.get("meeting_user_ids", [])) - gmr = GetManyRequest( - "meeting_user", list(meeting_user_ids), ["user_id", "vote_delegated_to_id"] - ) - gm_result = self.datastore.get_many( - [gmr], - lock_result=False, - ) - meeting_users = gm_result.get("meeting_user", {}).values() - - mu_to_user_id = {} - if meeting.get("users_enable_vote_delegations"): - # fetch vote delegations - delegated_to_mu_ids = list( - {id_ for mu in meeting_users if (id_ := mu.get("vote_delegated_to_id"))} - ) - if delegated_to_mu_ids: - gmr = GetManyRequest("meeting_user", delegated_to_mu_ids, ["user_id"]) - mu_to_user_id = self.datastore.get_many( - [gmr], - lock_result=False, - ).get("meeting_user", {}) - - gmr = GetManyRequest( - "user", - [mu["user_id"] for mu in meeting_users], - ["is_present_in_meeting_ids"], - ) - users = self.datastore.get_many([gmr], lock_result=False).get("user", {}) - - for mu in meeting_users: - entitled_users.append( - { - "voted": mu["user_id"] in all_voted_users, - "present": poll["meeting_id"] - in users[mu["user_id"]].get("is_present_in_meeting_ids", []), - "user_id": mu["user_id"], - "vote_delegated_to_user_id": ( - mu_to_user_id[vote_mu_id]["user_id"] - if (vote_mu_id := mu.get("vote_delegated_to_id")) - and meeting.get("users_enable_vote_delegations") - else None - ), - } - ) - - return entitled_users - - -class PollHistoryMixin(Action): - poll_history_information: str - - def get_history_information(self) -> HistoryInformation | None: - # no datastore access necessary if information is in payload - polls = self.get_instances_with_fields(["content_object_id"]) - return { - poll["content_object_id"]: [ - f"{self.get_history_title(poll)} {self.poll_history_information}" - ] - for poll in polls - } - - def get_history_title(self, poll: dict[str, Any]) -> str: - content_collection = collection_from_fqid(poll["content_object_id"]) - if content_collection == "assignment": - return "Ballot" - return "Voting" diff --git a/openslides_backend/action/actions/poll/publish.py b/openslides_backend/action/actions/poll/publish.py deleted file mode 100644 index 972820483a..0000000000 --- a/openslides_backend/action/actions/poll/publish.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin -from openslides_backend.shared.typing import HistoryInformation - -from ....models.models import Poll -from ....shared.exceptions import ActionException -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .mixins import PollHistoryMixin, PollPermissionMixin, StopControl - - -@register_action("poll.publish") -class PollPublishAction( - ExtendHistoryMixin, - StopControl, - UpdateAction, - PollPermissionMixin, - PollHistoryMixin, -): - """ - Action to publish a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema() - extend_history_to = "content_object_id" - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["state"], - ) - if poll.get("state") not in [Poll.STATE_FINISHED, Poll.STATE_STARTED]: - raise ActionException( - f"Cannot publish poll {instance['id']}, because it is not in state finished or started." - ) - if poll["state"] == Poll.STATE_STARTED: - self.on_stop(instance) - - instance["state"] = Poll.STATE_PUBLISHED - return instance - - def get_history_information(self) -> HistoryInformation | None: - polls = self.get_instances_with_fields(["content_object_id", "state"]) - return { - poll["content_object_id"]: [ - f"{self.get_history_title(poll)} {'stopped/' if poll['state'] != Poll.STATE_FINISHED else ''}published" - ] - for poll in polls - } diff --git a/openslides_backend/action/actions/poll/reset.py b/openslides_backend/action/actions/poll/reset.py deleted file mode 100644 index 3a71209753..0000000000 --- a/openslides_backend/action/actions/poll/reset.py +++ /dev/null @@ -1,138 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin - -from ....models.models import Poll -from ....services.database.interface import GetManyRequest -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ...util.typing import ActionData -from ..option.set_auto_fields import OptionSetAutoFields -from ..vote.delete import VoteDelete -from .mixins import PollHistoryMixin, PollPermissionMixin - - -@register_action("poll.reset") -class PollResetAction( - ExtendHistoryMixin, UpdateAction, PollPermissionMixin, PollHistoryMixin -): - """ - Action to reset a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema() - poll_history_information = "reset" - extend_history_to = "content_object_id" - - def prefetch(self, action_data: ActionData) -> None: - result = self.datastore.get_many( - [ - GetManyRequest( - "poll", - list({instance["id"] for instance in action_data}), - [ - "content_object_id", - "meeting_id", - "type", - "voted_ids", - "option_ids", - "global_option_id", - ], - ), - ], - use_changed_models=False, - ) - polls = result["poll"].values() - meeting_ids = list({poll["meeting_id"] for poll in polls}) - option_ids = [ - option_id - for poll in polls - if poll.get("option_ids") - for option_id in poll["option_ids"] - ] - requests = [ - GetManyRequest( - "meeting", - meeting_ids, - [ - "is_active_in_organization_id", - "name", - ], - ), - GetManyRequest( - "option", - option_ids, - ["vote_ids"], - ), - ] - self.datastore.get_many(requests, use_changed_models=False) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - instance["state"] = Poll.STATE_CREATED - self.delete_all_votes(instance["id"]) - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), ["type"] - ) - instance["is_pseudoanonymized"] = poll.get("type") == Poll.TYPE_PSEUDOANONYMOUS - instance["voted_ids"] = [] - instance["entitled_users_at_stop"] = None - instance["votesvalid"] = None - instance["votesinvalid"] = None - instance["votescast"] = None - return instance - - def delete_all_votes(self, poll_id: int) -> None: - option_ids = self._get_option_ids(poll_id) - options = self._get_options(option_ids) - for option_id in options: - option = options[option_id] - if option.get("vote_ids"): - self._delete_votes(option["vote_ids"]) - self._clear_option_auto_fields(option_id) - - def _get_option_ids(self, poll_id: int) -> list[int]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, poll_id), - ["option_ids", "global_option_id"], - ) - option_ids = poll.get("option_ids", []) - if poll.get("global_option_id"): - option_ids.append(poll["global_option_id"]) - return option_ids - - def _get_options(self, option_ids: list[int]) -> dict[int, dict[str, Any]]: - get_many_request = GetManyRequest("option", option_ids, ["vote_ids"]) - gm_result = self.datastore.get_many( - [get_many_request], use_changed_models=False - ) - options: dict[int, dict[str, Any]] = gm_result.get("option", {}) - - return options - - def _delete_votes(self, vote_ids: list[int]) -> None: - action_data = [] - for id_ in vote_ids: - action_data.append({"id": id_}) - self.execute_other_action(VoteDelete, action_data) - - def _clear_option_auto_fields(self, option_id: int) -> None: - action_data = [ - { - "id": option_id, - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - } - ] - self.execute_other_action(OptionSetAutoFields, action_data) - - def get_on_success(self, action_data: ActionData) -> Callable[[], None]: - def on_success() -> None: - for instance in action_data: - self.vote_service.clear(instance["id"]) - - return on_success diff --git a/openslides_backend/action/actions/poll/set_state.py b/openslides_backend/action/actions/poll/set_state.py deleted file mode 100644 index 997ff8ccae..0000000000 --- a/openslides_backend/action/actions/poll/set_state.py +++ /dev/null @@ -1,17 +0,0 @@ -from ....models.models import Poll -from ...generics.update import UpdateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("poll.set_state", action_type=ActionType.BACKEND_INTERNAL) -class PollSetState(UpdateAction): - """ - Internal action to set state of a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema( - required_properties=["state"], - ) diff --git a/openslides_backend/action/actions/poll/start.py b/openslides_backend/action/actions/poll/start.py deleted file mode 100644 index 394c35b281..0000000000 --- a/openslides_backend/action/actions/poll/start.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin - -from ....models.models import Poll -from ....shared.exceptions import ActionException, VoteServiceException -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ...util.typing import ActionData -from ..projector_countdown.mixins import CountdownCommand, CountdownControl -from .mixins import PollHistoryMixin, PollPermissionMixin - - -@register_action("poll.start") -class PollStartAction( - ExtendHistoryMixin, - CountdownControl, - UpdateAction, - PollPermissionMixin, - PollHistoryMixin, -): - """ - Action to start a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema() - poll_history_information = "started" - extend_history_to = "content_object_id" - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["state", "meeting_id", "type"], - ) - if poll.get("type") == Poll.TYPE_ANALOG: - raise ActionException( - "Analog polls cannot be started. Please use poll.update instead to give votes." - ) - if poll.get("state") != Poll.STATE_CREATED: - raise ActionException( - f"Cannot start poll {instance['id']}, because it is not in state created." - ) - instance["state"] = Poll.STATE_STARTED - - # restart projector countdown given by the meeting - meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", poll["meeting_id"]), - [ - "poll_couple_countdown", - "poll_countdown_id", - ], - ) - if meeting.get("poll_couple_countdown") and meeting.get("poll_countdown_id"): - self.control_countdown( - meeting["poll_countdown_id"], CountdownCommand.RESTART - ) - - self.vote_service.start(instance["id"]) - - return instance - - def get_on_failure(self, action_data: ActionData) -> Callable[[], None]: - def on_failure() -> None: - for instance in action_data: - try: - self.vote_service.clear(instance["id"]) - except VoteServiceException as e: - self.logger.error(f"Error clearing vote {instance['id']}: {str(e)}") - - return on_failure diff --git a/openslides_backend/action/actions/poll/stop.py b/openslides_backend/action/actions/poll/stop.py deleted file mode 100644 index f02540d7a7..0000000000 --- a/openslides_backend/action/actions/poll/stop.py +++ /dev/null @@ -1,139 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin - -from ....models.models import Poll -from ....services.database.commands import GetManyRequest -from ....shared.exceptions import ActionException, VoteServiceException -from ....shared.patterns import fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ...util.typing import ActionData -from .mixins import PollHistoryMixin, PollPermissionMixin, StopControl - - -@register_action("poll.stop") -class PollStopAction( - ExtendHistoryMixin, - StopControl, - UpdateAction, - PollPermissionMixin, - PollHistoryMixin, -): - """ - Action to stop a poll. - """ - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema() - poll_history_information = "stopped" - extend_history_to = "content_object_id" - - def prefetch(self, action_data: ActionData) -> None: - result = self.datastore.get_many( - [ - GetManyRequest( - "poll", - list({instance["id"] for instance in action_data}), - [ - "content_object_id", - "meeting_id", - "state", - "voted_ids", - "pollmethod", - "global_option_id", - "entitled_group_ids", - ], - ), - ], - use_changed_models=False, - ) - polls = result["poll"].values() - meeting_ids = list({poll["meeting_id"] for poll in polls}) - requests = [ - GetManyRequest( - "meeting", - meeting_ids, - [ - "poll_couple_countdown", - "poll_countdown_id", - "users_enable_vote_weight", - "vote_ids", - ], - ), - GetManyRequest( - "group", - list( - { - group_id - for poll in polls - for group_id in poll.get("entitled_group_ids", []) - } - ), - ["meeting_user_ids"], - ), - ] - result = self.datastore.get_many( - requests, - use_changed_models=False, - lock_result=False, - ) - groups = result["group"].values() - result = self.datastore.get_many( - [ - GetManyRequest( - "meeting_user", - list( - { - meeting_user_id - for group in groups - for meeting_user_id in group.get("meeting_user_ids", []) - } - ), - ["user_id"], - ), - ], - use_changed_models=False, - lock_result=False, - ) - meeting_users = result["meeting_user"].values() - self.datastore.get_many( - [ - GetManyRequest( - "user", - list({mu["user_id"] for mu in meeting_users}), - [ - "poll_voted_ids", - "delegated_vote_ids", - "vote_ids", - ], - ), - ], - use_changed_models=False, - lock_result=False, - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["state", "meeting_id", "voted_ids"], - ) - if poll.get("state") != Poll.STATE_STARTED: - raise ActionException( - f"Cannot stop poll {instance['id']}, because it is not in state started." - ) - instance["state"] = Poll.STATE_FINISHED - self.on_stop(instance) - return instance - - def get_on_success(self, action_data: ActionData) -> Callable[[], None]: - def on_success() -> None: - for instance in action_data: - try: - self.vote_service.clear(instance["id"]) - except VoteServiceException as e: - self.logger.error(f"Error clearing vote {instance['id']}: {str(e)}") - - return on_success diff --git a/openslides_backend/action/actions/poll/update.py b/openslides_backend/action/actions/poll/update.py deleted file mode 100644 index 0c13950e2e..0000000000 --- a/openslides_backend/action/actions/poll/update.py +++ /dev/null @@ -1,275 +0,0 @@ -from typing import Any - -from psycopg.types.json import Jsonb - -from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin -from openslides_backend.services.database.interface import PartialModel - -from ....models.models import Poll -from ....shared.exceptions import ActionException -from ....shared.patterns import collection_from_fqid, fqid_from_collection_and_id -from ...generics.update import UpdateAction -from ...mixins.forbid_anonymous_group_mixin import ForbidAnonymousGroupMixin -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from .base import base_check_onehundred_percent_base -from .mixins import PollHistoryMixin, PollPermissionMixin, PollValidationMixin - - -@register_action("poll.update") -class PollUpdateAction( - PollValidationMixin, - ExtendHistoryMixin, - UpdateAction, - PollPermissionMixin, - PollHistoryMixin, - ForbidAnonymousGroupMixin, -): - """ - Action to update a poll. - """ - - internal_fields = [ - "entitled_users_at_stop", - ] - - model = Poll() - schema = DefaultSchema(Poll()).get_update_schema( - optional_properties=[ - "pollmethod", - "min_votes_amount", - "max_votes_amount", - "max_votes_per_option", - "global_yes", - "global_no", - "global_abstain", - "entitled_group_ids", - "title", - "description", - "onehundred_percent_base", - "votesvalid", - "votesinvalid", - "votescast", - "backend", - "live_voting_enabled", - *internal_fields, - ], - additional_optional_fields={ - "publish_immediately": {"type": "boolean"}, - }, - ) - poll_history_information = "updated" - extend_history_to = "content_object_id" - - def validate_fields(self, instance: dict[str, Any]) -> dict[str, Any]: - super().validate_instance(instance) - if not self.internal and any( - forbidden := {key for key in instance if key in self.internal_fields} - ): - raise ActionException(f"data must not contain {forbidden} properties") - return super().validate_fields(instance) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - [ - "state", - "type", - "entitled_users_at_stop", - "content_object_id", - "pollmethod", - "max_votes_amount", - "global_yes", - "option_ids", - ], - ) - - self.check_entitled_users_at_stop(instance, poll) - - state_change = self.check_state_change(instance, poll) - - self.check_onehundred_percent_base(instance) - - not_allowed: list[str] = [] - if not poll.get("state") == Poll.STATE_CREATED: - for key in ( - "pollmethod", - "type", - "min_votes_amount", - "max_votes_amount", - "max_votes_per_option", - "global_yes", - "global_no", - "global_abstain", - "backend", - "live_voting_enabled", - ): - if key in instance: - not_allowed.append(key) - if ( - poll.get("state") != Poll.STATE_CREATED - or poll.get("type") == Poll.TYPE_ANALOG - ): - if "entitled_group_ids" in instance: - not_allowed.append("entitled_group_ids") - if not poll.get("type") == Poll.TYPE_ANALOG: - for key in ( - "votesvalid", - "votesinvalid", - "votescast", - "publish_immediately", - ): - if key in instance: - not_allowed.append(key) - - if not_allowed: - raise ActionException( - "Following options are not allowed in this state and type: " - + ", ".join(not_allowed) - ) - - # check named and live_voting_enabled - global_yes_for_check = instance.get("global_yes") or poll.get("global_yes") - pollmethod_for_check = instance.get("pollmethod") or poll.get("pollmethod") - max_votes_amount_for_check = instance.get("max_votes_amount") or poll.get( - "max_votes_amount" - ) - option_ids: list[int] = poll.get("option_ids", []) - length_for_check = len(option_ids) - content_object_id = ( - self.datastore.get( - fqid_from_collection_and_id("option", option_ids[0]), - ["content_object_id"], - ).get("content_object_id") - if length_for_check - else None - ) - is_list_poll = ( - collection_from_fqid(content_object_id) == "poll_candidate_list" - if content_object_id - else False - ) - if instance.get("live_voting_enabled") and not ( - poll["type"] == Poll.TYPE_NAMED - and ( - collection_from_fqid(poll["content_object_id"]) == "motion" - or ( - collection_from_fqid(poll["content_object_id"]) == "assignment" - and ( - not global_yes_for_check - and pollmethod_for_check == "Y" - and max_votes_amount_for_check == 1 - ) - or is_list_poll - or ( - (pollmethod_for_check == "YNA" or pollmethod_for_check == "YN") - and length_for_check == 1 - ) - ) - ) - ): - raise ActionException( - "live_voting_enabled only allowed for named motion polls and some named assignment polls." - ) - - if poll["type"] == Poll.TYPE_ANALOG and ( - base := instance.get("onehundred_percent_base") - ) in ( - Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED, - Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED_PRESENT, - ): - raise ActionException( - f"onehundred_percent_base: value {base} is not allowed for analog." - ) - if state_change: - instance["state"] = Poll.STATE_FINISHED - if ( - poll["type"] == Poll.TYPE_ANALOG - and ( - instance.get("state") == Poll.STATE_FINISHED - or poll["state"] == Poll.STATE_FINISHED - ) - and instance.get("publish_immediately") - ): - instance["state"] = Poll.STATE_PUBLISHED - - # set votescast, votesvalid, votesinvalid defaults - if poll["type"] == Poll.TYPE_ANALOG: - for field in ("votescast", "votesvalid", "votesinvalid"): - if field not in instance: - instance[field] = "-2.000000" - - instance.pop("publish_immediately", None) - self.check_anonymous_not_in_list_fields(instance, ["entitled_group_ids"]) - - if raw_entitled_users_at_stop := instance.get("entitled_users_at_stop"): - if isinstance(raw_entitled_users_at_stop, list): - instance["entitled_users_at_stop"] = Jsonb(raw_entitled_users_at_stop) - - return instance - - def check_onehundred_percent_base(self, instance: dict[str, Any]) -> None: - onehundred_percent_base = instance.get("onehundred_percent_base") - if "pollmethod" in instance: - pollmethod = instance["pollmethod"] - else: - poll = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["pollmethod"], - ) - pollmethod = poll.get("pollmethod") - base_check_onehundred_percent_base(pollmethod, onehundred_percent_base) - - def check_state_change( - self, instance: dict[str, Any], poll: dict[str, Any] - ) -> bool: - if poll.get("type") != Poll.TYPE_ANALOG: - return False - if poll.get("state") != Poll.STATE_CREATED: - return False - check_fields = ( - "votesvalid", - "votesinvalid", - "votescast", - ) - for field in check_fields: - if instance.get(field): - return True - return False - - def check_entitled_users_at_stop( - self, instance: dict[str, Any], poll: PartialModel - ) -> None: - field = "entitled_users_at_stop" - if field not in instance: - return - if field not in poll: - raise ActionException( - "Can not set 'entitled_users_at_stop' via poll.update" - ) - if not isinstance(instance[field], list) or any( - entry - for entry in instance[field] - if not isinstance(entry, dict) or not entry.get("user_id") - ): - raise ActionException("'entitled_users_at_stop' has the wrong format") - original_main_user_id_to_entry: dict[int, dict[str, Any]] = { - date["user_id"]: date for date in poll[field] - } - new_main_user_id_to_entry: dict[int, dict[str, Any]] = { - date["user_id"]: date for date in instance[field] - } - original_ids = {user_id for user_id in original_main_user_id_to_entry} - new_ids = {user_id for user_id in new_main_user_id_to_entry} - if (len(original_ids) != len(new_ids)) or len(original_ids.difference(new_ids)): - raise ActionException( - "Can not change essential 'entitled_users_at_stop' data via poll.update" - ) - if any( - original_entry.get(field) != new_main_user_id_to_entry[user_id].get(field) - for user_id, original_entry in original_main_user_id_to_entry.items() - for field in ["voted", "present", "user_id", "vote_delegated_to_user_id"] - ): - raise ActionException( - "Can not change essential 'entitled_users_at_stop' data via poll.update" - ) diff --git a/openslides_backend/action/actions/poll_ballot/__init__.py b/openslides_backend/action/actions/poll_ballot/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_ballot/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_ballot/delete.py b/openslides_backend/action/actions/poll_ballot/delete.py new file mode 100644 index 0000000000..13faf6f769 --- /dev/null +++ b/openslides_backend/action/actions/poll_ballot/delete.py @@ -0,0 +1,15 @@ +from ....models.models import PollBallot +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("poll_ballot.delete", action_type=ActionType.BACKEND_INTERNAL) +class PollBallotDelete(DeleteAction): + """ + Action to delete a poll_ballot. + """ + + model = PollBallot() + schema = DefaultSchema(PollBallot()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_ballot_user/__init__.py b/openslides_backend/action/actions/poll_ballot_user/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_ballot_user/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_ballot_user/delete.py b/openslides_backend/action/actions/poll_ballot_user/delete.py new file mode 100644 index 0000000000..ca70e6158f --- /dev/null +++ b/openslides_backend/action/actions/poll_ballot_user/delete.py @@ -0,0 +1,15 @@ +from ....models.models import PollBallotUser +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("poll_ballot_user.delete", action_type=ActionType.BACKEND_INTERNAL) +class PollBallotUserDelete(DeleteAction): + """ + Action to delete a poll_ballot_user. + """ + + model = PollBallotUser() + schema = DefaultSchema(PollBallotUser()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_candidate/__init__.py b/openslides_backend/action/actions/poll_candidate/__init__.py deleted file mode 100644 index e2b11ce4af..0000000000 --- a/openslides_backend/action/actions/poll_candidate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import create, delete # noqa diff --git a/openslides_backend/action/actions/poll_candidate/create.py b/openslides_backend/action/actions/poll_candidate/create.py deleted file mode 100644 index 933b72cb1f..0000000000 --- a/openslides_backend/action/actions/poll_candidate/create.py +++ /dev/null @@ -1,23 +0,0 @@ -from ....models.models import PollCandidate -from ...generics.create import CreateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("poll_candidate.create", action_type=ActionType.BACKEND_INTERNAL) -class PollCandidateCreate(CreateAction): - """ - Internal action to create a poll candiate. It gets the meeting_id from - its poll candidate list, - """ - - model = PollCandidate() - schema = DefaultSchema(PollCandidate()).get_create_schema( - required_properties=[ - "user_id", - "poll_candidate_list_id", - "weight", - "meeting_id", - ] - ) diff --git a/openslides_backend/action/actions/poll_candidate/delete.py b/openslides_backend/action/actions/poll_candidate/delete.py deleted file mode 100644 index 8677db3f6d..0000000000 --- a/openslides_backend/action/actions/poll_candidate/delete.py +++ /dev/null @@ -1,15 +0,0 @@ -from ....models.models import PollCandidate -from ...generics.delete import DeleteAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("poll_candidate.delete", action_type=ActionType.BACKEND_INTERNAL) -class PollCandidateDelete(DeleteAction): - """ - Internal action to delete a poll candidate. - """ - - model = PollCandidate() - schema = DefaultSchema(PollCandidate()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_candidate_list/__init__.py b/openslides_backend/action/actions/poll_candidate_list/__init__.py deleted file mode 100644 index e2b11ce4af..0000000000 --- a/openslides_backend/action/actions/poll_candidate_list/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import create, delete # noqa diff --git a/openslides_backend/action/actions/poll_candidate_list/create.py b/openslides_backend/action/actions/poll_candidate_list/create.py deleted file mode 100644 index 5aaa2ebad0..0000000000 --- a/openslides_backend/action/actions/poll_candidate_list/create.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from ....models.models import PollCandidateList -from ....shared.schema import required_id_schema -from ...generics.create import CreateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action -from ..poll_candidate.create import PollCandidateCreate - -entry_schema = { - "description": "An entry in a poll_candidate_list create schema", - "type": "object", - "properties": { - "user_id": required_id_schema, - "weight": {"type": "integer"}, - }, - "additionalProperties": False, -} - - -@register_action("poll_candidate_list.create", action_type=ActionType.BACKEND_INTERNAL) -class PollCandidateListCreate(CreateAction): - """ - Internal action to create a poll_candidate_list. - """ - - model = PollCandidateList() - schema = DefaultSchema(PollCandidateList()).get_create_schema( - required_properties=["option_id", "meeting_id"], - additional_required_fields={ - "entries": {"type": "array", "items": entry_schema, "minItems": 1} - }, - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - instance = super().update_instance(instance) - entries = instance.pop("entries") - self.apply_instance(instance) - res = self.execute_other_action( - PollCandidateCreate, - [ - { - "user_id": entry["user_id"], - "weight": entry["weight"], - "poll_candidate_list_id": instance["id"], - "meeting_id": instance["meeting_id"], - } - for entry in entries - ], - ) - instance["poll_candidate_ids"] = [r["id"] for r in res] # type: ignore - return instance diff --git a/openslides_backend/action/actions/poll_candidate_list/delete.py b/openslides_backend/action/actions/poll_candidate_list/delete.py deleted file mode 100644 index 4b87e9c187..0000000000 --- a/openslides_backend/action/actions/poll_candidate_list/delete.py +++ /dev/null @@ -1,15 +0,0 @@ -from ....models.models import PollCandidateList -from ...generics.delete import DeleteAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("poll_candidate_list.delete", action_type=ActionType.BACKEND_INTERNAL) -class PollCandidateListDelete(DeleteAction): - """ - Internal action to delete a poll candidate. - """ - - model = PollCandidateList() - schema = DefaultSchema(PollCandidateList()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_config_approval/__init__.py b/openslides_backend/action/actions/poll_config_approval/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_approval/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_config_approval/delete.py b/openslides_backend/action/actions/poll_config_approval/delete.py new file mode 100644 index 0000000000..a3c6301848 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_approval/delete.py @@ -0,0 +1,15 @@ +from ....models.models import PollConfigApproval +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("poll_config_approval.delete", action_type=ActionType.BACKEND_INTERNAL) +class PollConfigApprovalDelete(DeleteAction): + """ + Action to delete a poll_config_approval. + """ + + model = PollConfigApproval() + schema = DefaultSchema(PollConfigApproval()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_config_rating_approval/__init__.py b/openslides_backend/action/actions/poll_config_rating_approval/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_rating_approval/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_config_rating_approval/delete.py b/openslides_backend/action/actions/poll_config_rating_approval/delete.py new file mode 100644 index 0000000000..65c4600e8d --- /dev/null +++ b/openslides_backend/action/actions/poll_config_rating_approval/delete.py @@ -0,0 +1,17 @@ +from ....models.models import PollConfigRatingApproval +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action( + "poll_config_rating_approval.delete", action_type=ActionType.BACKEND_INTERNAL +) +class PollConfigRatingApprovalDelete(DeleteAction): + """ + Action to delete a poll_config_rating_approval. + """ + + model = PollConfigRatingApproval() + schema = DefaultSchema(PollConfigRatingApproval()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_config_rating_score/__init__.py b/openslides_backend/action/actions/poll_config_rating_score/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_rating_score/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_config_rating_score/delete.py b/openslides_backend/action/actions/poll_config_rating_score/delete.py new file mode 100644 index 0000000000..845590d404 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_rating_score/delete.py @@ -0,0 +1,17 @@ +from ....models.models import PollConfigRatingScore +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action( + "poll_config_rating_score.delete", action_type=ActionType.BACKEND_INTERNAL +) +class PollConfigRatingScoreDelete(DeleteAction): + """ + Action to delete a poll_config_rating_score. + """ + + model = PollConfigRatingScore() + schema = DefaultSchema(PollConfigRatingScore()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_config_selection/__init__.py b/openslides_backend/action/actions/poll_config_selection/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_selection/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_config_selection/delete.py b/openslides_backend/action/actions/poll_config_selection/delete.py new file mode 100644 index 0000000000..156b407a75 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_selection/delete.py @@ -0,0 +1,17 @@ +from ....models.models import PollConfigSelection +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action( + "poll_config_selection.delete", action_type=ActionType.BACKEND_INTERNAL +) +class PollConfigSelectionDelete(DeleteAction): + """ + Action to delete a poll_config_selection. + """ + + model = PollConfigSelection() + schema = DefaultSchema(PollConfigSelection()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_config_stv_scottish/__init__.py b/openslides_backend/action/actions/poll_config_stv_scottish/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_stv_scottish/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_config_stv_scottish/delete.py b/openslides_backend/action/actions/poll_config_stv_scottish/delete.py new file mode 100644 index 0000000000..0b92158515 --- /dev/null +++ b/openslides_backend/action/actions/poll_config_stv_scottish/delete.py @@ -0,0 +1,17 @@ +from ....models.models import PollConfigStvScottish +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action( + "poll_config_stv_scottish.delete", action_type=ActionType.BACKEND_INTERNAL +) +class PollConfigStvScottishDelete(DeleteAction): + """ + Action to delete a poll_config_stv_scottish. + """ + + model = PollConfigStvScottish() + schema = DefaultSchema(PollConfigStvScottish()).get_delete_schema() diff --git a/openslides_backend/action/actions/poll_option/__init__.py b/openslides_backend/action/actions/poll_option/__init__.py new file mode 100644 index 0000000000..1fc925d0d8 --- /dev/null +++ b/openslides_backend/action/actions/poll_option/__init__.py @@ -0,0 +1 @@ +from . import delete # noqa diff --git a/openslides_backend/action/actions/poll_option/delete.py b/openslides_backend/action/actions/poll_option/delete.py new file mode 100644 index 0000000000..837629fea2 --- /dev/null +++ b/openslides_backend/action/actions/poll_option/delete.py @@ -0,0 +1,15 @@ +from ....models.models import PollOption +from ...generics.delete import DeleteAction +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action + + +@register_action("poll_option.delete", action_type=ActionType.BACKEND_INTERNAL) +class PollOptionDelete(DeleteAction): + """ + Action to delete a poll_option. + """ + + model = PollOption() + schema = DefaultSchema(PollOption()).get_delete_schema() diff --git a/openslides_backend/action/actions/user/merge_mixins.py b/openslides_backend/action/actions/user/merge_mixins.py index 107393d946..15429f7bf3 100644 --- a/openslides_backend/action/actions/user/merge_mixins.py +++ b/openslides_backend/action/actions/user/merge_mixins.py @@ -1,6 +1,7 @@ from typing import Any from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import update_history_information from ....models.models import ( AssignmentCandidate, @@ -10,6 +11,7 @@ MotionSupporter, MotionWorkingGroupSpeaker, PersonalNote, + Poll, Speaker, ) from ....services.database.commands import GetManyRequest @@ -98,9 +100,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: assignment_ids.add(data["assignment_id"]) for assignment_id in assignment_ids: - information[fqid_from_collection_and_id("assignment", assignment_id)] = [ - "Candidates merged" - ] + update_history_information( + information, + fqid_from_collection_and_id("assignment", assignment_id), + ["Candidates merged"], + ) return information @@ -127,11 +131,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: motion_ids.add(data["motion_id"]) for motion_id in motion_ids: - fqid = fqid_from_collection_and_id("motion", motion_id) - if fqid not in information: - information[fqid] = ["Submitters merged"] - else: - information[fqid].append("Submitters merged") + update_history_information( + information, + fqid_from_collection_and_id("motion", motion_id), + ["Submitters merged"], + ) return information @@ -154,11 +158,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: motion_ids.add(data["motion_id"]) for motion_id in motion_ids: - fqid = fqid_from_collection_and_id("motion", motion_id) - if fqid not in information: - information[fqid] = ["Supporters merged"] - else: - information[fqid].append("Supporters merged") + update_history_information( + information, + fqid_from_collection_and_id("motion", motion_id), + ["Supporters merged"], + ) return information @@ -225,6 +229,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: "chat_message_ids", "group_ids", "structure_level_ids", + "history_entry_ids", + "poll_option_ids", # throw error if conflict on same poll + "acting_ballot_ids", # throw error if conflict on same poll + "represented_ballot_ids", # throw error if conflict on same poll ], "deep_merge": { "assignment_candidate_ids": "assignment_candidate", @@ -275,26 +283,36 @@ def get_merge_comparison_hash( case _: return super().get_merge_comparison_hash(collection, model) - def check_polls_helper(self, meeting_user_ids: list[int]) -> list[str]: + def check_polls(self, into: PartialModel, other_models: list[PartialModel]) -> None: messages: list[str] = [] + group_ids: set[int] = set() + meeting_ids: set[int] = set() + meeting_id_by_group_ids: dict[int, int] = {} + + meeting_user_ids = [ + mu_id + for user in [into, *other_models] + for mu_id in user.get("meeting_user_ids", []) + ] meeting_users = self.datastore.get_many( [ GetManyRequest( "meeting_user", meeting_user_ids, [ - "vote_delegations_from_ids", - "vote_delegated_to_id", + "user_id", "meeting_id", "group_ids", + "vote_delegations_from_ids", + "vote_delegated_to_id", + "poll_option_ids", + "acting_ballot_ids", + "represented_ballot_ids", ], ) ] - ).get("meeting_user", {}) + )["meeting_user"] - group_ids: set[int] = set() - meeting_ids: set[int] = set() - meeting_id_by_group_ids: dict[int, int] = {} for m_user in meeting_users.values(): if len(g_ids := m_user.get("group_ids", [])): meeting_id_by_group_ids.update( @@ -306,7 +324,7 @@ def check_polls_helper(self, meeting_user_ids: list[int]) -> list[str]: polls = self.datastore.filter( "poll", And( - FilterOperator("state", "=", "started"), + FilterOperator("state", "=", Poll.STATE_STARTED), Or( FilterOperator("meeting_id", "=", meeting_id) for meeting_id in meeting_ids @@ -359,7 +377,7 @@ def check_polls_helper(self, meeting_user_ids: list[int]) -> list[str]: if len( bad_users := [ id_ - for id_ in meeting_user_ids + for id_ in list(meeting_users.keys()) if id_ in {*delegator_meeting_user_ids, *proxy_meeting_user_ids} ] ): @@ -371,4 +389,70 @@ def check_polls_helper(self, meeting_user_ids: list[int]) -> list[str]: messages.append( f"some of the selected users are delegating votes to each other in meeting(s) {', '.join(bad_meetings)}" ) - return messages + + ballot_poll_ids_per_user_id: dict[int, set[int]] = {} + option_poll_ids_per_user_id: dict[int, set[int]] = {} + for meeting_user in meeting_users.values(): + if len( + (o_ids := meeting_user.get("poll_option_ids", [])) + + ( + b_ids := list( + { + id_ + for id_ in [ + *meeting_user.get("acting_ballot_ids", []), + *meeting_user.get("represented_ballot_ids", []), + ] + } + ) + ) + ): + many_models = self.datastore.get_many( + [ + GetManyRequest("poll_option", o_ids, ["poll_id"]), + GetManyRequest("poll_ballot_user", b_ids, ["poll_id"]), + ] + ) + if o_ids: + option_poll_ids_per_user_id.setdefault( + meeting_user["user_id"], set() + ).update( + { + option["poll_id"] + for option in many_models["poll_option"].values() + } + ) + ballot_data = many_models["poll_ballot_user"] + ballot_poll_ids_per_user_id.setdefault( + meeting_user["user_id"], set() + ).update({ballot["poll_id"] for ballot in ballot_data.values()}) + ballot_conflicts = self._get_conflicts_between_users( + ballot_poll_ids_per_user_id + ) + option_conflicts = self._get_conflicts_between_users( + option_poll_ids_per_user_id + ) + if len(ballot_conflicts): + messages.append( + f"among the selected users multiple voted in poll(s) {', '.join([str(id_) for id_ in ballot_conflicts])}" + ) + if len(option_conflicts): + messages.append( + f"multiple of the selected users are among the options in poll(s) {', '.join([str(id_) for id_ in option_conflicts])}" + ) + + if len(messages): + raise ActionException( + f"Cannot carry out merge into user/{into['id']}, because {' and '.join(messages)}" + ) + + def _get_conflicts_between_users(self, ids_map: dict[int, set[int]]) -> set[int]: + seen_ids = set() + duplicates = set() + for ids in ids_map.values(): + for id_ in ids: + if id_ in seen_ids: + duplicates.add(id_) + else: + seen_ids.add(id_) + return duplicates diff --git a/openslides_backend/action/actions/user/merge_together.py b/openslides_backend/action/actions/user/merge_together.py index fe281c134d..2dbe1e5e4f 100644 --- a/openslides_backend/action/actions/user/merge_together.py +++ b/openslides_backend/action/actions/user/merge_together.py @@ -1,8 +1,10 @@ -from typing import Any, cast - -from psycopg.types.json import Jsonb +from typing import Any from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import ( + update_history_information, + update_history_information_multi, +) from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin from ....models.models import User @@ -31,7 +33,6 @@ from ..motion_working_group_speaker.update import MotionWorkingGroupSpeakerUpdateAction from ..personal_note.create import PersonalNoteCreateAction from ..personal_note.update import PersonalNoteUpdateAction -from ..poll.update import PollUpdateAction from ..speaker.create_for_merge import SpeakerCreateForMerge from ..speaker.delete import SpeakerDeleteAction from ..speaker.update import SpeakerUpdate @@ -108,11 +109,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: "committee_management_ids", "history_entry_ids", "history_position_ids", - "option_ids", # throw error if conflict on same poll - "poll_voted_ids", # throw error if conflict on same poll - "vote_ids", # throw error if conflict on same poll - "delegated_vote_ids", # throw error if conflict on same poll - "poll_candidate_ids", # error if multiple of the users are on the same list, else merge ], "deep_create_merge": { "meeting_user_ids": "meeting_user", @@ -172,43 +168,6 @@ def get_updated_instances(self, action_data: ActionData) -> ActionData: raise ActionException( "Users cannot be part of different merges at the same time" ) - secondary_id_to_main_ids = { - user_id: instance["id"] - for instance in action_data - for user_id in instance.get("user_ids", []) - } - polls = self.datastore.filter( - "poll", - And( - FilterOperator("entitled_users_at_stop", "!=", None), - FilterOperator("entitled_users_at_stop", "!=", Jsonb([])), - ), - ["entitled_users_at_stop"], - ) - poll_payloads: list[dict[str, Any]] = [] - for id_, poll in polls.items(): - entitled: list[dict[str, Any]] = poll["entitled_users_at_stop"] - changed = False - for vote in entitled: - if ( - user_id := (vote.get("user_merged_into_id") or vote.get("user_id")) - ) in secondary_id_to_main_ids: - vote["user_merged_into_id"] = secondary_id_to_main_ids[user_id] - changed = True - if ( - user_id := ( - vote.get("delegation_user_merged_into_id") - or vote.get("vote_delegated_to_user_id") - ) - ) in secondary_id_to_main_ids: - vote["delegation_user_merged_into_id"] = secondary_id_to_main_ids[ - user_id - ] - changed = True - if changed: - poll_payloads.append({"id": id_, "entitled_users_at_stop": entitled}) - if len(poll_payloads): - self.execute_other_action(PollUpdateAction, poll_payloads) return super().get_updated_instances(action_data) def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: @@ -404,141 +363,6 @@ def call_other_actions( [{"id": id_} for id_ in to_delete], ) - def check_polls(self, into: PartialModel, other_models: list[PartialModel]) -> None: - all_models = [into, *other_models] - vote_poll_ids_per_user_id: dict[int, set[int]] = { - user["id"]: {poll_id for poll_id in user.get("poll_voted_ids", [])} - for user in [into, *other_models] - } - option_poll_ids_per_user_id: dict[int, set[int]] = {} - candidate_list_ids_per_user_id: dict[int, set[int]] = {} - meeting_user_ids: list[int] = [] - for model in all_models: - if len( - (pc_ids := model.get("poll_candidate_ids", [])) - + (o_ids := model.get("option_ids", [])) - + ( - v_ids := list( - { - id_ - for id_ in [ - *model.get("vote_ids", []), - *model.get("delegated_vote_ids", []), - ] - } - ) - ) - ): - get_many_requests = [ - GetManyRequest( - "poll_candidate", pc_ids, ["poll_candidate_list_id"] - ), - GetManyRequest( - "option", - o_ids, - ["poll_id"], - ), - GetManyRequest( - "vote", - v_ids, - ["option_id"], - ), - ] - many_models = self.datastore.get_many(get_many_requests) - if pc_ids: - candidate_list_ids_per_user_id[model["id"]] = { - poll_candidate["poll_candidate_list_id"] - for poll_candidate in many_models["poll_candidate"].values() - if poll_candidate.get("poll_candidate_list_id") - } - if o_ids: - option_poll_ids_per_user_id[model["id"]] = { - option["poll_id"] - for option in many_models["option"].values() - if option.get("poll_id") - } - vote_data = many_models["vote"] - vote_poll_ids_per_user_id[model["id"]] = { - *vote_poll_ids_per_user_id.get(model["id"], set()), - *{ - cast(int, option["poll_id"]) - for option in self.datastore.get_many( - [ - GetManyRequest( - "option", - list( - { - id_ - for id_ in [ - vote["option_id"] - for vote in vote_data.values() - if vote.get("option_id") - ] - } - ), - ["poll_id"], - ) - ] - )["option"].values() - if option.get("poll_id") - }, - } - meeting_user_ids += model.get("meeting_user_ids", []) - voting_conflicts = { - poll_id - for id1, poll_ids1 in vote_poll_ids_per_user_id.items() - for id2, poll_ids2 in vote_poll_ids_per_user_id.items() - for poll_id in poll_ids1.intersection(poll_ids2) - if id1 != id2 - } - option_conflicts = { - poll_id - for id1, poll_ids1 in option_poll_ids_per_user_id.items() - for id2, poll_ids2 in option_poll_ids_per_user_id.items() - for poll_id in poll_ids1.intersection(poll_ids2) - if id1 != id2 - } - candidate_list_conflicts = { - list_id - for id1, list_ids1 in candidate_list_ids_per_user_id.items() - for id2, list_ids2 in candidate_list_ids_per_user_id.items() - for list_id in list_ids1.intersection(list_ids2) - if id1 != id2 - } - messages: list[str] = self.check_polls_helper(meeting_user_ids) - if len(voting_conflicts): - messages.append( - f"among the selected users multiple voted in poll(s) {', '.join([str(id_) for id_ in voting_conflicts])}" - ) - if len(option_conflicts): - messages.append( - f"multiple of the selected users are among the options in poll(s) {', '.join([str(id_) for id_ in option_conflicts])}" - ) - if len(candidate_list_conflicts): - lists = self.datastore.get_many( - [ - GetManyRequest( - "poll_candidate_list", - list(candidate_list_conflicts), - ["option_id"], - ) - ], - lock_result=False, - )["poll_candidate_list"] - option_ids = {c_list["option_id"] for c_list in lists.values()} - options = self.datastore.get_many( - [GetManyRequest("option", list(option_ids), ["poll_id"])], - lock_result=False, - )["option"] - poll_ids = {option["poll_id"] for option in options.values()} - messages.append( - f"multiple of the selected users are in the same candidate list in poll(s) {', '.join([str(id_) for id_ in poll_ids])}" - ) - if len(messages): - raise ActionException( - f"Cannot carry out merge into user/{into['id']}, because {' and '.join(messages)}" - ) - def get_merge_comparison_hash( self, collection: Collection, model: PartialModel ) -> int | str | tuple[int | str, ...]: @@ -575,9 +399,10 @@ def handle_special_field( ) return None case "is_present_in_meeting_ids": - all_meeting_ids = self.get_meeting_ids_per_user( - [into_, *ranked_others] - ) + all_meeting_ids = { + user["id"]: set(user.get("meeting_ids", [])) + for user in [into_, *ranked_others] + } check_meeting_ids = all_meeting_ids[into_["id"]] present_meeting_ids: set[int] = set(into_.get(field, [])) for other in ranked_others: @@ -608,30 +433,6 @@ def handle_special_field( collection, field, into_, ranked_others, update_operations ) - def get_meeting_ids_per_user( - self, users: list[PartialModel] - ) -> dict[int, set[int]]: - meeting_users = self.datastore.filter( - "meeting_user", - And( - FilterOperator("group_ids", "!=", []), - Or(FilterOperator("user_id", "=", user["id"]) for user in users), - ), - ["meeting_id"], - ) - return { - user["id"]: { - meeting_id - for meeting_user_id in user.get("meeting_user_ids", []) - if ( - meeting_id := meeting_users.get(meeting_user_id, {}).get( - "meeting_id" - ) - ) - } - for user in users - } - def get_full_history_information(self) -> HistoryInformation | None: information = super().get_full_history_information() or {} for data, ids, is_transfer in self._history_replacement_groups["user"]: @@ -652,12 +453,17 @@ def get_full_history_information(self) -> HistoryInformation | None: deleted_string = " and ".join( ["{}" for i in range(len(deleted_fqids))] ) - information[main_fqid] = [ - "Updated with data from " + deleted_string, - *deleted_fqids, - ] - for deleted_fqid in deleted_fqids: - information[deleted_fqid] = ["Merged into {}", main_fqid] + update_history_information( + information, + main_fqid, + [ + "Updated with data from " + deleted_string, + *deleted_fqids, + ], + ) + update_history_information_multi( + information, deleted_fqids, ["Merged into {}", main_fqid] + ) else: raise BadCodingException("No id found for user history generation") return information diff --git a/openslides_backend/action/actions/user/set_present.py b/openslides_backend/action/actions/user/set_present.py index d9ea8650bb..0c0748878e 100644 --- a/openslides_backend/action/actions/user/set_present.py +++ b/openslides_backend/action/actions/user/set_present.py @@ -1,8 +1,10 @@ from typing import Any +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.typing import HistoryInformation from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin +from ....action.mixins.meeting_user_helper import get_meeting_user from ....models.models import User from ....permissions.management_levels import OrganizationManagementLevel from ....permissions.permission_helper import ( @@ -40,24 +42,37 @@ def get_updated_instances(self, action_data: ActionData) -> ActionData: add meeting_id if present is True. remove meeting_id if present is False. """ + self.base_history_information = {} for instance in action_data: meeting_id = instance.pop("meeting_id") present = instance.pop("present") user = self.datastore.get( fqid_from_collection_and_id(self.model.collection, instance["id"]), - ["is_present_in_meeting_ids"], + ["is_present_in_meeting_ids", "meeting_ids"], ) + self.base_history_information[instance["id"]] = { + "present": present, + "meeting_id": meeting_id, + "all_meeting_ids": user.get("meeting_ids", []), + } if present: if meeting_id not in user.get("is_present_in_meeting_ids", []): - instance["is_present_in_meeting_ids"] = user.get( - "is_present_in_meeting_ids", [] - ) + [meeting_id] + is_present = user.get("is_present_in_meeting_ids", []) + [ + meeting_id + ] + instance["is_present_in_meeting_ids"] = is_present + self.base_history_information[instance["id"]][ + "is_present_in_meeting_ids" + ] = is_present yield instance elif present is False: is_present = user.get("is_present_in_meeting_ids", []) if meeting_id in is_present: is_present.remove(meeting_id) instance["is_present_in_meeting_ids"] = is_present + self.base_history_information[instance["id"]][ + "is_present_in_meeting_ids" + ] = is_present yield instance def check_permissions(self, instance: dict[str, Any]) -> None: @@ -93,10 +108,29 @@ def check_permissions(self, instance: dict[str, Any]) -> None: raise PermissionDenied("You are not allowed to set present.") def get_history_information(self) -> HistoryInformation | None: - return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): [ - f"Set {'not ' if not instance['present'] else ''}present in meeting {{}}", - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] - for instance in self.action_data - } + information: HistoryInformation = {} + for user_id, data in self.base_history_information.items(): + present = data["present"] + meeting_id = data["meeting_id"] + information[fqid_from_collection_and_id(self.model.collection, user_id)] = ( + build_history_information_data( + [ + f"Set {'not ' if not present else ''}present in meeting {{}}", + fqid_from_collection_and_id("meeting", meeting_id), + ], + ) + ) + if meeting_id in data["all_meeting_ids"]: + meeting_user = get_meeting_user( + self.datastore, + meeting_id, + user_id, + ["id"], + ) + if meeting_user: + information[ + fqid_from_collection_and_id("meeting_user", meeting_user["id"]) + ] = build_history_information_data( + structured_information={"is_present": present} + ) + return information diff --git a/openslides_backend/action/actions/user/update.py b/openslides_backend/action/actions/user/update.py index 0350041301..91359c23dc 100644 --- a/openslides_backend/action/actions/user/update.py +++ b/openslides_backend/action/actions/user/update.py @@ -57,11 +57,6 @@ class UserUpdate( internal_id_fields = [ "is_present_in_meeting_ids", - "option_ids", - "poll_candidate_ids", - "poll_voted_ids", - "vote_ids", - "delegated_vote_ids", "history_entry_ids", "history_position_ids", ] diff --git a/openslides_backend/action/actions/user/user_mixins.py b/openslides_backend/action/actions/user/user_mixins.py index 5d0b652383..68870246c4 100644 --- a/openslides_backend/action/actions/user/user_mixins.py +++ b/openslides_backend/action/actions/user/user_mixins.py @@ -4,6 +4,7 @@ from openslides_backend.services.database.commands import GetManyRequest from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from openslides_backend.shared.util import ONE_ORGANIZATION_FQID @@ -154,12 +155,12 @@ def meeting_user_set_data(self, instance: dict[str, Any]) -> None: class UpdateHistoryMixin(Action): def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} # Scan the instances and collect the info for the history information # Copy instances first since they are modified for instance in deepcopy(self.instances): - instance_information = [] + entries = [] # Fetch the current instance from the db to diff with the given instance db_instance = self.datastore.get( @@ -190,22 +191,24 @@ def get_history_information(self) -> HistoryInformation | None: "default_vote_weight", ] if any(field in instance for field in update_fields): - instance_information.append("Personal data changed") + entries.append("Personal data changed") # other fields if "organization_management_level" in instance: - instance_information.append("Organization Management Level changed") + entries.append("Organization Management Level changed") if "committee_management_ids" in instance: - instance_information.append("Committee management changed") + entries.append("Committee management changed") if "is_active" in instance: if instance["is_active"]: - instance_information.append("Set active") + entries.append("Set active") else: - instance_information.append("Set inactive") + entries.append("Set inactive") - if instance_information: - information[fqid_from_collection_and_id("user", instance["id"])] = ( - instance_information + if entries: + update_history_information( + information, + fqid_from_collection_and_id("user", instance["id"]), + entries, ) return information diff --git a/openslides_backend/action/actions/vote/__init__.py b/openslides_backend/action/actions/vote/__init__.py deleted file mode 100644 index 2d608629cc..0000000000 --- a/openslides_backend/action/actions/vote/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import anonymize, create, delete, update # noqa diff --git a/openslides_backend/action/actions/vote/anonymize.py b/openslides_backend/action/actions/vote/anonymize.py deleted file mode 100644 index 3a0ea659a8..0000000000 --- a/openslides_backend/action/actions/vote/anonymize.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Any - -from ....models.models import Vote -from ...generics.update import UpdateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("vote.anonymize", action_type=ActionType.BACKEND_INTERNAL) -class VoteAnonymize(UpdateAction): - """ - Action to anonymize a vote by removing the user ids. - """ - - model = Vote() - schema = DefaultSchema(Vote()).get_update_schema() - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - instance["user_id"] = None - instance["delegated_user_id"] = None - return instance diff --git a/openslides_backend/action/actions/vote/create.py b/openslides_backend/action/actions/vote/create.py deleted file mode 100644 index a02bc677fa..0000000000 --- a/openslides_backend/action/actions/vote/create.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import cast - -from openslides_backend.action.util.typing import ActionData -from openslides_backend.services.database.commands import GetManyRequest - -from ....models.models import Vote -from ...mixins.create_action_with_inferred_meeting import ( - CreateActionWithInferredMeeting, -) -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("vote.create", action_type=ActionType.BACKEND_INTERNAL) -class VoteCreate(CreateActionWithInferredMeeting): - """ - Internal action to create a vote. - """ - - model = Vote() - schema = DefaultSchema(Vote()).get_create_schema( - required_properties=[ - "weight", - "value", - "option_id", - "user_token", - ], - optional_properties=["delegated_user_id", "user_id"], - ) - - relation_field_for_meeting = "option_id" - - def prefetch(self, action_data: ActionData) -> None: - self.datastore.get_many( - [ - GetManyRequest( - "option", - list({instance["option_id"] for instance in action_data}), - ["meeting_id", "vote_ids"], - ), - ], - use_changed_models=False, - ) - meeting_users = self.datastore.get_many( - [ - GetManyRequest( - "meeting_user", - list( - { - cast(int, instance.get(fname)) - for instance in action_data - for fname in ( - "meeting_user_id", - "delegated_meeting_user_id", - ) - if instance.get(fname) - } - ), - ["id", "user_id", "vote_ids", "delegated_vote_ids"], - ), - ], - use_changed_models=False, - lock_result=False, - )["meeting_user"] - - self.datastore.get_many( - [ - GetManyRequest( - "user", - list({mu["user_id"] for mu in meeting_users.values()}), - ["id", "poll_voted_ids"], - ), - ], - use_changed_models=False, - lock_result=False, - ) diff --git a/openslides_backend/action/actions/vote/delete.py b/openslides_backend/action/actions/vote/delete.py deleted file mode 100644 index 099e24459c..0000000000 --- a/openslides_backend/action/actions/vote/delete.py +++ /dev/null @@ -1,15 +0,0 @@ -from ....models.models import Vote -from ...generics.delete import DeleteAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("vote.delete", action_type=ActionType.BACKEND_INTERNAL) -class VoteDelete(DeleteAction): - """ - Action to delete votes. - """ - - model = Vote() - schema = DefaultSchema(Vote()).get_delete_schema() diff --git a/openslides_backend/action/actions/vote/update.py b/openslides_backend/action/actions/vote/update.py deleted file mode 100644 index fa34189f1c..0000000000 --- a/openslides_backend/action/actions/vote/update.py +++ /dev/null @@ -1,15 +0,0 @@ -from ....models.models import Vote -from ...generics.update import UpdateAction -from ...util.action_type import ActionType -from ...util.default_schema import DefaultSchema -from ...util.register import register_action - - -@register_action("vote.update", action_type=ActionType.BACKEND_INTERNAL) -class VoteUpdate(UpdateAction): - """ - Internal action to update a vote. - """ - - model = Vote() - schema = DefaultSchema(Vote()).get_update_schema(required_properties=["weight"]) diff --git a/openslides_backend/action/actions/vote/user_token_helper.py b/openslides_backend/action/actions/vote/user_token_helper.py deleted file mode 100644 index e993d0922f..0000000000 --- a/openslides_backend/action/actions/vote/user_token_helper.py +++ /dev/null @@ -1,5 +0,0 @@ -from ...util.crypto import get_random_string - - -def get_user_token() -> str: - return get_random_string(16) diff --git a/openslides_backend/action/generics/delete.py b/openslides_backend/action/generics/delete.py index b5a39361de..f8d4c6624c 100644 --- a/openslides_backend/action/generics/delete.py +++ b/openslides_backend/action/generics/delete.py @@ -116,6 +116,3 @@ def is_meeting_to_be_deleted(self, meeting_id: int) -> bool: return self.datastore.is_to_be_deleted( fqid_from_collection_and_id("meeting", meeting_id) ) - - def is_to_be_deleted(self, fqid: FullQualifiedId) -> bool: - return self.datastore.is_to_be_deleted(fqid) diff --git a/openslides_backend/http/views/action_view.py b/openslides_backend/http/views/action_view.py index 72d2f57150..8db0e5303e 100644 --- a/openslides_backend/http/views/action_view.py +++ b/openslides_backend/http/views/action_view.py @@ -7,7 +7,6 @@ from ...i18n.translator import Translator from ...migrations.migration_helper import MigrationHelper from ...migrations.migration_manager import MigrationManager -from ...services.auth.interface import AUTHENTICATION_HEADER, COOKIE_NAME from ...services.postgresql.db_connection_handling import get_new_os_conn from ...shared.env import DEV_PASSWORD from ...shared.exceptions import AuthenticationException, ServerError @@ -39,11 +38,6 @@ def action_route(self, request: Request) -> RouteResponse: user_id, access_token = self.get_user_id_from_headers( request.headers, request.cookies ) - # Set Headers and Cookies in services. - self.services.vote().set_authentication( - request.headers.get(AUTHENTICATION_HEADER, ""), - request.cookies.get(COOKIE_NAME, ""), - ) # Handle request. handler = ActionHandler(self.env, self.services, self.logging) diff --git a/openslides_backend/models/checker.py b/openslides_backend/models/checker.py index f165599568..9774905bf4 100644 --- a/openslides_backend/models/checker.py +++ b/openslides_backend/models/checker.py @@ -188,7 +188,18 @@ def check_timestamp(value: Any) -> bool: collection for collection, model in model_registry.items() if model().has_field("meeting_id") -} | {"meeting", "user", "mediafile"} +} | { + "meeting", + "user", + "mediafile", + "poll_ballot", + "poll_ballot_user", + "poll_config_approval", + "poll_config_selection", + "poll_config_rating_score", + "poll_config_rating_approval", + "poll_option", +} class Checker: @@ -465,6 +476,13 @@ def check_relation( if collection == "user" and field == "organization_id": return + if ( + collection == "vote" + and field == "poll_id" + and str(model["poll_id"]) in self.data.get("poll", {}) + ): + return + if isinstance(field_type, RelationField): foreign_id = model[field] if not foreign_id: diff --git a/openslides_backend/models/mixins.py b/openslides_backend/models/mixins.py index ee78890774..52dd1cb51b 100644 --- a/openslides_backend/models/mixins.py +++ b/openslides_backend/models/mixins.py @@ -18,9 +18,11 @@ class AgendaItemModelMixin: "countdown", "assignment_poll", "motion_poll", - "poll", + "topic_poll", ) +POLL_TYPES = ("assignment", "motion", "topic") + class MeetingModelMixin: LOGO_PLACES = ( @@ -74,22 +76,41 @@ def reverse_default_projectors(cls) -> list[str]: ] +class MeetingPollDefaultModelMixin: + POLL_TYPE_FIELDS = [ + f"used_as_{poll_type}_poll_config_in_meeting_id" for poll_type in POLL_TYPES + ] + + class PollModelMixin: STATE_CREATED = "created" STATE_STARTED = "started" STATE_FINISHED = "finished" - STATE_PUBLISHED = "published" - TYPE_ANALOG = "analog" - TYPE_NAMED = "named" - TYPE_PSEUDOANONYMOUS = "pseudoanonymous" + VISIBILITY_MANUALLY = "manually" + VISIBILITY_NAMED = "named" + VISIBILITY_OPEN = "open" + VISIBILITY_SECRET = "secret" - ONEHUNDRED_PERCENT_BASE_Y = "Y" - ONEHUNDRED_PERCENT_BASE_YN = "YN" - ONEHUNDRED_PERCENT_BASE_YNA = "YNA" - ONEHUNDRED_PERCENT_BASE_N = "N" + METHOD_APPROVAL = "approval" + METHOD_SELECTION = "selection" + METHOD_RATING_SCORE = "rating_score" + METHOD_RATING_APPROVAL = "rating_approval" + METHOD_RATING_STV_SCOTTISH = "stv_scottish" + + CONFIG_TYPE_APPROVAL = "poll_config_approval" + CONFIG_TYPE_SELECTION = "poll_config_selection" + CONFIG_TYPE_RATING_SCORE = "poll_config_rating_score" + CONFIG_TYPE_RATING_APPROVAL = "poll_config_rating_approval" + CONFIG_TYPE_RATING_STV_SCOTTISH = "poll_config_stv_scottish" + + ONEHUNDRED_PERCENT_BASE_NO_GENERAL = "no_general" + ONEHUNDRED_PERCENT_BASE_YES_NO = "yes_no" ONEHUNDRED_PERCENT_BASE_VALID = "valid" ONEHUNDRED_PERCENT_BASE_CAST = "cast" ONEHUNDRED_PERCENT_BASE_ENTITLED = "entitled" ONEHUNDRED_PERCENT_BASE_ENTITLED_PRESENT = "entitled_present" ONEHUNDRED_PERCENT_BASE_DISABLED = "disabled" + + OPTION_TYPE_TEXT = "text" + OPTION_TYPE_MEETING_USER = "meeting_user" diff --git a/openslides_backend/models/models.py b/openslides_backend/models/models.py index f73be1d753..a516c0f113 100644 --- a/openslides_backend/models/models.py +++ b/openslides_backend/models/models.py @@ -2,7 +2,12 @@ from . import fields from .base import Model -from .mixins import AgendaItemModelMixin, MeetingModelMixin, PollModelMixin +from .mixins import ( + AgendaItemModelMixin, + MeetingModelMixin, + MeetingPollDefaultModelMixin, + PollModelMixin, +) class ActionWorker(Model): @@ -354,12 +359,15 @@ class Group(Model): in_array_constraints={ "enum": [ "agenda_item.can_manage", + "agenda_item.can_manage_polls", + "agenda_item.can_see_polls", "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_polls", "assignment.can_see", "chat.can_manage", "list_of_speakers.can_be_speaker", @@ -381,11 +389,11 @@ class Group(Model): "motion.can_manage", "motion.can_manage_metadata", "motion.can_manage_polls", + "motion.can_see_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", @@ -499,17 +507,16 @@ class Group(Model): 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"} + used_in_meeting_poll_default_ids = fields.RelationListField( + to={"meeting_poll_default": "group_ids"}, + is_view_field=True, + is_primary=True, + write_fields=( + "nm_group_uimpdi_meeting_poll_default_t", + "group_id", + "meeting_poll_default_id", + [], + ), ) meeting_id = fields.RelationField( to={"meeting": "group_ids"}, required=True, constant=True @@ -522,12 +529,15 @@ class HistoryEntry(Model): id = fields.IntegerField(required=True, constant=True) entries = fields.TextArrayField() + structured_information = fields.JSONField() original_model_id = fields.CharField(constant=True) model_id = fields.GenericRelationField( to={ - "user": "history_entry_ids", - "motion": "history_entry_ids", "assignment": "history_entry_ids", + "meeting_user": "history_entry_ids", + "motion": "history_entry_ids", + "poll": "history_entry_ids", + "user": "history_entry_ids", } ) position_id = fields.RelationField( @@ -857,53 +867,6 @@ class Meeting(Model, MeetingModelMixin): 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(default=True) - 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, @@ -934,91 +897,68 @@ class Meeting(Model, MeetingModelMixin): 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", + assignment_poll_default_method = fields.CharField( + default="selection", constraints={ "enum": [ - "Y", - "YN", - "YNA", - "N", - "valid", - "cast", - "entitled", - "entitled_present", - "disabled", + "approval", + "selection", + "rating_score", + "rating_approval", + "stv_scottish", ] }, ) - assignment_poll_default_group_ids = fields.RelationListField( - to={"group": "used_as_assignment_poll_default_id"}, is_view_field=True + assignment_poll_config_id = fields.RelationField( + to={"meeting_poll_default": "used_as_assignment_poll_config_in_meeting_id"} ) - assignment_poll_default_backend = fields.CharField( - default="fast", constraints={"enum": ["long", "fast"]} + motion_poll_config_id = fields.RelationField( + to={"meeting_poll_default": "used_as_motion_poll_config_in_meeting_id"} ) - poll_ballot_paper_selection = fields.CharField( + topic_poll_default_method = fields.CharField( + default="selection", constraints={ "enum": [ - "NUMBER_OF_DELEGATES", - "NUMBER_OF_ALL_PARTICIPANTS", - "CUSTOM_NUMBER", + "approval", + "selection", + "rating_score", + "rating_approval", + "stv_scottish", ] - } + }, ) - 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", + topic_poll_config_id = fields.RelationField( + to={"meeting_poll_default": "used_as_topic_poll_config_in_meeting_id"} + ) + poll_enable_max_votes_per_option = fields.BooleanField(default=False) + poll_default_live_voting_enabled = fields.BooleanField( + default=False, constraints={ - "enum": [ - "Y", - "YN", - "YNA", - "N", - "valid", - "cast", - "entitled", - "entitled_present", - "disabled", - ] + "description": "Defines default 'poll.published' before finished option suggested to user. Is not used in the validations." }, ) - 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_allow_invalid = fields.BooleanField( + default=False, + constraints={ + "description": "Defines default `poll.allow_invalid` option suggested to user." + }, ) - poll_default_live_voting_enabled = fields.BooleanField( + poll_default_allow_vote_split = fields.BooleanField( default=False, constraints={ - "description": "Defines default 'poll.live_voting_enabled' option suggested to user. Is not used in the validations." + "description": "Defines default `poll.allow_vote_split` option suggested to user." }, ) 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 + poll_projection_name_order_first = fields.CharField( + required=True, + default="last_name", + constraints={"enum": ["first_name", "last_name"]}, ) + poll_projection_max_columns = fields.IntegerField(required=True, default=6) projector_ids = fields.RelationListField( to={"projector": "meeting_id"}, on_delete=fields.OnDelete.CASCADE, @@ -1158,17 +1098,10 @@ class Meeting(Model, MeetingModelMixin): 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"}, + poll_default_ids = fields.RelationListField( + to={"meeting_poll_default": "meeting_id"}, on_delete=fields.OnDelete.CASCADE, is_view_field=True, - is_primary=True, ) assignment_ids = fields.RelationListField( to={"assignment": "meeting_id"}, @@ -1366,8 +1299,8 @@ class Meeting(Model, MeetingModelMixin): is_view_field=True, required=True, ) - default_projector_poll_ids = fields.RelationListField( - to={"projector": "used_as_default_projector_for_poll_in_meeting_id"}, + default_projector_topic_poll_ids = fields.RelationListField( + to={"projector": "used_as_default_projector_for_topic_poll_in_meeting_id"}, is_view_field=True, required=True, ) @@ -1502,6 +1435,44 @@ class MeetingMediafile(Model): ) +class MeetingPollDefault(Model, MeetingPollDefaultModelMixin): + collection = "meeting_poll_default" + verbose_name = "meeting poll default" + + id = fields.IntegerField(required=True, constant=True) + sort_result_by_votes = fields.BooleanField(default=True) + visibility = fields.CharField( + constraints={"enum": ["manually", "named", "open", "secret"]} + ) + allow_abstain = fields.BooleanField(default=True) + allow_nota = fields.BooleanField(default=False) + strike_out = fields.BooleanField(default=False) + onehundred_percent_base = fields.CharField(default="valid") + group_ids = fields.RelationListField( + to={"group": "used_in_meeting_poll_default_ids"}, + is_view_field=True, + write_fields=( + "nm_group_uimpdi_meeting_poll_default_t", + "meeting_poll_default_id", + "group_id", + [], + ), + ) + display_chart = fields.CharField() + used_as_assignment_poll_config_in_meeting_id = fields.RelationField( + to={"meeting": "assignment_poll_config_id"}, is_view_field=True, constant=True + ) + used_as_motion_poll_config_in_meeting_id = fields.RelationField( + to={"meeting": "motion_poll_config_id"}, is_view_field=True, constant=True + ) + used_as_topic_poll_config_in_meeting_id = fields.RelationField( + to={"meeting": "topic_poll_config_id"}, is_view_field=True, constant=True + ) + meeting_id = fields.RelationField( + to={"meeting": "poll_default_ids"}, required=True, constant=True + ) + + class MeetingUser(Model): collection = "meeting_user" verbose_name = "meeting user" @@ -1547,6 +1518,15 @@ class MeetingUser(Model): vote_delegations_from_ids = fields.RelationListField( to={"meeting_user": "vote_delegated_to_id"}, is_view_field=True ) + poll_option_ids = fields.RelationListField( + to={"poll_option": "meeting_user_id"}, is_view_field=True, is_primary=True + ) + acting_ballot_ids = fields.RelationListField( + to={"poll_ballot_user": "acting_meeting_user_id"}, is_view_field=True + ) + represented_ballot_ids = fields.RelationListField( + to={"poll_ballot_user": "represented_meeting_user_id"}, is_view_field=True + ) chat_message_ids = fields.RelationListField( to={"chat_message": "meeting_user_id"}, is_view_field=True ) @@ -1572,6 +1552,9 @@ class MeetingUser(Model): [], ), ) + history_entry_ids = fields.RelationListField( + to={"history_entry": "model_id"}, is_view_field=True + ) class Motion(Model): @@ -1737,12 +1720,6 @@ class Motion(Model): 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, @@ -2137,39 +2114,6 @@ class MotionWorkingGroupSpeaker(Model): ) -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" @@ -2321,55 +2265,56 @@ class Poll(Model, PollModelMixin): id = fields.IntegerField(required=True, constant=True) title = fields.CharField(required=True) - description = fields.CharField() - type = fields.CharField( + config_id = fields.GenericRelationField( + to={ + "poll_config_stv_scottish": "poll_id", + "poll_config_rating_approval": "poll_id", + "poll_config_rating_score": "poll_id", + "poll_config_selection": "poll_id", + "poll_config_approval": "poll_id", + }, + on_delete=fields.OnDelete.CASCADE, required=True, - constraints={"enum": ["analog", "named", "pseudoanonymous", "cryptographic"]}, ) - backend = fields.CharField( - required=True, default="fast", constraints={"enum": ["long", "fast"]} + option_ids = fields.RelationListField( + to={"poll_option": "poll_id"}, + on_delete=fields.OnDelete.CASCADE, + is_view_field=True, ) - is_pseudoanonymized = fields.BooleanField() - pollmethod = fields.CharField( - required=True, constraints={"enum": ["Y", "YN", "YNA", "N"]} + visibility = fields.CharField( + required=True, constraints={"enum": ["manually", "named", "open", "secret"]} ) 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", + default="created", constraints={"enum": ["created", "started", "finished"]} + ) + result = fields.TextField( constraints={ - "enum": [ - "Y", - "YN", - "YNA", - "N", - "valid", - "cast", - "entitled", - "entitled_present", - "disabled", - ] + "description": "Calculated result. The format depends on the value in poll/method. Can be manually set when visibility is set to manually." + } + ) + published = fields.BooleanField( + default=False, constraints={"description": "If true, users can see the result."} + ) + anonymized = fields.BooleanField( + default=False, + constraints={ + "description": "Set to true, after finished was called with anonymize." }, ) - votesvalid = fields.DecimalField() - votesinvalid = fields.DecimalField() - votescast = fields.DecimalField() - entitled_users_at_stop = fields.JSONField() - live_voting_enabled = fields.BooleanField( + allow_invalid = fields.BooleanField( default=False, constraints={ - "description": "If true, the vote service sends the votes of the users to the autoupdate service." + "description": "If true, the vote service does not validate. This is always the case for secret polls." }, ) + allow_vote_split = fields.BooleanField( + default=False, + constraints={"description": "If true, users can split there vote."}, + ) + live_voting_enabled = fields.BooleanField( + default=False, + constraints={"description": "If true, on start, publish will be set to true."}, + ) sequential_number = fields.IntegerField( required=True, read_only=True, @@ -2384,22 +2329,17 @@ class Poll(Model, PollModelMixin): required=True, constant=True, ) - option_ids = fields.RelationListField( - to={"option": "poll_id"}, + ballot_ids = fields.RelationListField( + to={"poll_ballot": "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"}, + ballot_user_ids = fields.RelationListField( + to={"poll_ballot_user": "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"}, @@ -2414,43 +2354,173 @@ class Poll(Model, PollModelMixin): meeting_id = fields.RelationField( to={"meeting": "poll_ids"}, required=True, constant=True ) + history_entry_ids = fields.RelationListField( + to={"history_entry": "model_id"}, is_view_field=True + ) -class PollCandidate(Model): - collection = "poll_candidate" - verbose_name = "poll candidate" +class PollBallot(Model): + collection = "poll_ballot" + verbose_name = "poll ballot" id = fields.IntegerField(required=True, constant=True) - poll_candidate_list_id = fields.RelationField( - to={"poll_candidate_list": "poll_candidate_ids"}, required=True, constant=True + weight = fields.DecimalField(constant=True, default="1.000000") + split = fields.BooleanField(default=False) + value = fields.TextField(constant=True) + poll_id = fields.RelationField( + to={"poll": "ballot_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 + poll_ballot_user_id = fields.RelationField( + to={"poll_ballot_user": "poll_ballot_id"} ) -class PollCandidateList(Model): - collection = "poll_candidate_list" - verbose_name = "poll candidate list" +class PollBallotUser(Model): + collection = "poll_ballot_user" + verbose_name = "poll ballot user" 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, + poll_id = fields.RelationField( + to={"poll": "ballot_user_ids"}, required=True, constant=True ) - meeting_id = fields.RelationField( - to={"meeting": "poll_candidate_list_ids"}, required=True, constant=True + poll_ballot_id = fields.RelationField( + to={"poll_ballot": "poll_ballot_user_id"}, is_view_field=True ) - option_id = fields.RelationField( - to={"option": "content_object_id"}, - is_view_field=True, + acting_meeting_user_id = fields.RelationField( + to={"meeting_user": "acting_ballot_ids"}, required=True + ) + represented_meeting_user_id = fields.RelationField( + to={"meeting_user": "represented_ballot_ids"}, required=True + ) + + +class PollConfigApproval(Model): + collection = "poll_config_approval" + verbose_name = "poll config approval" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField( + to={"poll": "config_id"}, is_view_field=True, required=True + ) + allow_abstain = fields.BooleanField(default=True) + onehundred_percent_base = fields.CharField( required=True, - constant=True, + constraints={ + "enum": [ + "yes_no", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + + +class PollConfigRatingApproval(Model): + collection = "poll_config_rating_approval" + verbose_name = "poll config rating approval" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField( + to={"poll": "config_id"}, is_view_field=True, required=True + ) + max_options_amount = fields.IntegerField(default=0) + min_options_amount = fields.IntegerField(default=0) + max_yes_amount = fields.IntegerField() + allow_abstain = fields.BooleanField(default=True) + onehundred_percent_base = fields.CharField( + required=True, + constraints={ + "enum": [ + "yes_no", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + + +class PollConfigRatingScore(Model): + collection = "poll_config_rating_score" + verbose_name = "poll config rating score" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField( + to={"poll": "config_id"}, is_view_field=True, required=True + ) + max_options_amount = fields.IntegerField(default=0) + min_options_amount = fields.IntegerField(default=0) + max_votes_per_option = fields.IntegerField(default=0) + max_vote_sum = fields.IntegerField(default=0) + min_vote_sum = fields.IntegerField(default=0) + onehundred_percent_base = fields.CharField( + required=True, + constraints={ + "enum": [ + "yes_no", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, + ) + + +class PollConfigSelection(Model): + collection = "poll_config_selection" + verbose_name = "poll config selection" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField( + to={"poll": "config_id"}, is_view_field=True, required=True + ) + max_options_amount = fields.IntegerField(default=0) + min_options_amount = fields.IntegerField(default=0) + allow_nota = fields.BooleanField(default=False) + strike_out = fields.BooleanField(default=False) + onehundred_percent_base = fields.CharField( + required=True, + constraints={ + "enum": [ + "no_general", + "valid", + "cast", + "entitled", + "entitled_present", + "disabled", + ] + }, ) + display_chart = fields.CharField() + + +class PollConfigStvScottish(Model): + collection = "poll_config_stv_scottish" + verbose_name = "poll config stv scottish" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField( + to={"poll": "config_id"}, is_view_field=True, required=True + ) + posts = fields.IntegerField(default=1, constraints={"minimum": 1}) + + +class PollOption(Model): + collection = "poll_option" + verbose_name = "poll option" + + id = fields.IntegerField(required=True, constant=True) + poll_id = fields.RelationField(to={"poll": "option_ids"}, required=True) + weight = fields.IntegerField() + text = fields.CharField() + meeting_user_id = fields.RelationField(to={"meeting_user": "poll_option_ids"}) class Projection(Model): @@ -2586,8 +2656,8 @@ class Projector(Model): 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"} + used_as_default_projector_for_topic_poll_in_meeting_id = fields.RelationField( + to={"meeting": "default_projector_topic_poll_ids"} ) meeting_id = fields.RelationField( to={"meeting": "projector_ids"}, required=True, constant=True @@ -2954,23 +3024,6 @@ class User(Model): 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 @@ -2989,21 +3042,3 @@ class User(Model): 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 - ) diff --git a/openslides_backend/permissions/permissions.py b/openslides_backend/permissions/permissions.py index 82e34bef4c..d394f4f941 100644 --- a/openslides_backend/permissions/permissions.py +++ b/openslides_backend/permissions/permissions.py @@ -7,8 +7,10 @@ class _AgendaItem(Permission, StrEnum): CAN_MANAGE = "agenda_item.can_manage" + CAN_MANAGE_POLLS = "agenda_item.can_manage_polls" CAN_SEE = "agenda_item.can_see" CAN_SEE_INTERNAL = "agenda_item.can_see_internal" + CAN_SEE_POLLS = "agenda_item.can_see_polls" class _Assignment(Permission, StrEnum): @@ -17,6 +19,7 @@ class _Assignment(Permission, StrEnum): CAN_NOMINATE_OTHER = "assignment.can_nominate_other" CAN_NOMINATE_SELF = "assignment.can_nominate_self" CAN_SEE = "assignment.can_see" + CAN_SEE_POLLS = "assignment.can_see_polls" class _Chat(Permission, StrEnum): @@ -55,11 +58,11 @@ class _Motion(Permission, StrEnum): CAN_SEE = "motion.can_see" CAN_SEE_INTERNAL = "motion.can_see_internal" CAN_SEE_ORIGIN = "motion.can_see_origin" + CAN_SEE_POLLS = "motion.can_see_polls" CAN_SUPPORT = "motion.can_support" class _Poll(Permission, StrEnum): - CAN_MANAGE = "poll.can_manage" CAN_SEE_PROGRESS = "poll.can_see_progress" @@ -97,16 +100,19 @@ class Permissions: # Holds the corresponding parent for each permission. permission_parents: dict[Permission, list[Permission]] = { - _AgendaItem.CAN_SEE: [_AgendaItem.CAN_SEE_INTERNAL], - _AgendaItem.CAN_SEE_INTERNAL: [_AgendaItem.CAN_MANAGE], + _AgendaItem.CAN_SEE: [_AgendaItem.CAN_SEE_INTERNAL, _AgendaItem.CAN_SEE_INTERNAL], + _AgendaItem.CAN_SEE_INTERNAL: [_AgendaItem.CAN_MANAGE, _AgendaItem.CAN_SEE_POLLS], _AgendaItem.CAN_MANAGE: [], + _AgendaItem.CAN_SEE_POLLS: [_AgendaItem.CAN_MANAGE_POLLS], + _AgendaItem.CAN_MANAGE_POLLS: [], _Assignment.CAN_SEE: [ _Assignment.CAN_NOMINATE_OTHER, - _Assignment.CAN_MANAGE_POLLS, + _Assignment.CAN_SEE_POLLS, _Assignment.CAN_NOMINATE_SELF, ], _Assignment.CAN_NOMINATE_OTHER: [_Assignment.CAN_MANAGE], _Assignment.CAN_MANAGE: [], + _Assignment.CAN_SEE_POLLS: [_Assignment.CAN_MANAGE_POLLS], _Assignment.CAN_MANAGE_POLLS: [], _Assignment.CAN_NOMINATE_SELF: [], _Chat.CAN_MANAGE: [], @@ -131,7 +137,7 @@ class Permissions: _Motion.CAN_CREATE, _Motion.CAN_CREATE_AMENDMENTS, _Motion.CAN_FORWARD, - _Motion.CAN_MANAGE_POLLS, + _Motion.CAN_SEE_POLLS, _Motion.CAN_SUPPORT, _Motion.CAN_SEE_ORIGIN, ], @@ -141,11 +147,11 @@ class Permissions: _Motion.CAN_CREATE_AMENDMENTS: [_Motion.CAN_MANAGE], _Motion.CAN_FORWARD: [_Motion.CAN_MANAGE], _Motion.CAN_MANAGE: [], + _Motion.CAN_SEE_POLLS: [_Motion.CAN_MANAGE_POLLS], _Motion.CAN_MANAGE_POLLS: [], _Motion.CAN_SUPPORT: [], _Motion.CAN_SEE_ORIGIN: [], - _Poll.CAN_SEE_PROGRESS: [_Poll.CAN_MANAGE], - _Poll.CAN_MANAGE: [], + _Poll.CAN_SEE_PROGRESS: [], _Projector.CAN_SEE: [_Projector.CAN_MANAGE], _Projector.CAN_MANAGE: [], _Tag.CAN_MANAGE: [], diff --git a/openslides_backend/services/vote/adapter.py b/openslides_backend/services/vote/adapter.py deleted file mode 100644 index 67a000bbc8..0000000000 --- a/openslides_backend/services/vote/adapter.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import Any - -import requests -import simplejson as json - -from ...shared.exceptions import VoteServiceException -from ...shared.interfaces.logging import LoggingModule -from ..shared.authenticated_service import AuthenticatedService -from .interface import VoteService - - -class VoteAdapter(VoteService, AuthenticatedService): - """ - Adapter to connect to the vote service. - """ - - def __init__(self, vote_url: str, logging: LoggingModule) -> None: - self.url = vote_url - self.logger = logging.getLogger(__name__) - - def retrieve(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any: - response = self.make_request(endpoint, payload) - message = f"Vote service sends HTTP {response.status_code} with the following content: {str(response.content)}." - if response.status_code < 400: - self.logger.debug(message) - elif response.status_code == 500: - self.logger.error(message) - raise VoteServiceException( - "Vote service sends HTTP 500 Internal Server Error." - ) - else: - self.logger.error(message) - raise VoteServiceException(message) - if response.content: - return response.json() - - def make_request(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any: - if not self.access_token or not self.refresh_id: - raise VoteServiceException("You must be logged in to vote") - payload_json = json.dumps(payload, separators=(",", ":")) if payload else None - try: - return requests.post( - url=endpoint, - data=payload_json, - headers={ - "Content-Type": "application/json", - **self.get_auth_header(), - }, - cookies=self.get_auth_cookie(), - ) - except requests.exceptions.ConnectionError as e: - self.logger.error( - f"Cannot reach the vote service on {endpoint}. Error: {e}" - ) - raise VoteServiceException(f"Cannot reach the vote service on {endpoint}.") - - def start(self, id: int) -> None: - endpoint = self.get_endpoint("start", id) - self.retrieve(endpoint) - - def stop(self, id: int) -> dict[str, Any]: - endpoint = self.get_endpoint("stop", id) - return self.retrieve(endpoint) - - def clear(self, id: int) -> None: - endpoint = self.get_endpoint("clear", id) - self.retrieve(endpoint) - - def clear_all(self) -> None: - endpoint = self.get_endpoint("clear_all") - self.retrieve(endpoint) - - def get_endpoint(self, route: str, id: int | None = None) -> str: - return f"{self.url}/{route}" + (f"?id={id}" if id else "") diff --git a/openslides_backend/services/vote/interface.py b/openslides_backend/services/vote/interface.py deleted file mode 100644 index 7c22cb3af4..0000000000 --- a/openslides_backend/services/vote/interface.py +++ /dev/null @@ -1,23 +0,0 @@ -from abc import abstractmethod -from typing import Any, Protocol - -from ..shared.authenticated_service import AuthenticatedServiceInterface - - -class VoteService(AuthenticatedServiceInterface, Protocol): - """ - Interface of the vote service. - """ - - @abstractmethod - def start(self, id: int) -> None: ... - - @abstractmethod - def stop(self, id: int) -> dict[str, Any]: ... - - @abstractmethod - def clear(self, id: int) -> None: ... - - @abstractmethod - def clear_all(self) -> None: - """Only for testing purposes.""" diff --git a/openslides_backend/shared/base_service_provider.py b/openslides_backend/shared/base_service_provider.py index 908900b3c4..45ad03fb84 100644 --- a/openslides_backend/shared/base_service_provider.py +++ b/openslides_backend/shared/base_service_provider.py @@ -4,7 +4,6 @@ from openslides_backend.services.postgresql.db_connection_handling import ( get_current_os_conn_pool, ) -from openslides_backend.services.vote.interface import VoteService from openslides_backend.shared.interfaces.logging import Logger, LoggingModule from openslides_backend.shared.interfaces.services import Services @@ -18,7 +17,6 @@ class BaseServiceProvider: datastore: Database auth: AuthenticationService media: MediaService - vote: VoteService logging: LoggingModule logger: Logger @@ -34,7 +32,6 @@ def __init__( self.services = services self.auth = services.authentication() self.media = services.media() - self.vote_service = services.vote() self.datastore = datastore self.logging = logging os_conn_pool = get_current_os_conn_pool() diff --git a/openslides_backend/shared/env.py b/openslides_backend/shared/env.py index 209b52a7d7..faf2ab4385 100644 --- a/openslides_backend/shared/env.py +++ b/openslides_backend/shared/env.py @@ -47,10 +47,6 @@ class Environment(Env): "OPENSLIDES_LOGLEVEL": Loglevel.NOTSET.name, "OPENTELEMETRY_ENABLED": "false", "PRESENTER_PORT": "9003", - "VOTE_HOST": "vote", - "VOTE_PATH": "/internal/vote", - "VOTE_PORT": "9013", - "VOTE_PROTOCOL": "http", "DATABASE_HOST": "", "DATABASE_PORT": "5432", "DATABASE_NAME": "openslides", @@ -104,7 +100,7 @@ def get_loglevel(self) -> str: def get_service_url(self) -> dict[str, str]: service_url = {} # Extend the vars attribute with the lower case properties for the service URLs. - for service in ("media", "vote"): + for service in ("media",): key = service + "_url" service_url[key] = self.get_endpoint(service.upper()) return service_url diff --git a/openslides_backend/shared/exceptions.py b/openslides_backend/shared/exceptions.py index c1b95818e5..1316f9dafe 100644 --- a/openslides_backend/shared/exceptions.py +++ b/openslides_backend/shared/exceptions.py @@ -146,10 +146,6 @@ class MediaServiceException(ServiceException): pass -class VoteServiceException(ServiceException): - pass - - class PermissionDenied(ViewException): status_code = 403 diff --git a/openslides_backend/shared/export_helper.py b/openslides_backend/shared/export_helper.py index 1830c18803..5484a2b9f8 100644 --- a/openslides_backend/shared/export_helper.py +++ b/openslides_backend/shared/export_helper.py @@ -22,8 +22,6 @@ FORBIDDEN_FIELDS = ["forwarded_motion_ids"] -NON_CASCADING_MEETING_RELATION_LISTS = ["poll_candidate_list_ids", "poll_candidate_ids"] - HISTORY_FIELDS_PER_COLLECTION = { "meeting": ["relevant_history_entry_ids"], "user": ["history_entry_ids", "history_position_ids"], @@ -210,6 +208,30 @@ def export_meeting( user_ids.add(results["meeting_user"][id_]["user_id"]) add_users(list(user_ids), export, meeting_id, datastore, internal_target) + # Get related configs, options and ballots + if polls := export.get("poll"): + related_collections: dict[str, list[int]] = {} + for poll in polls.values(): + for collection_base_name in ["ballot", "ballot_user", "option"]: + if ids := poll.get(f"{collection_base_name}_ids"): + related_collections.setdefault( + f"poll_{collection_base_name}", list() + ).extend(ids) + config_collection, config_id = poll["config_id"].split("/") + related_collections.setdefault(config_collection, list()).append( + int(config_id) + ) + related_models = datastore.get_many( + [ + GetManyRequest(collection, ids, get_fields_for_export(collection)) + for collection, ids in related_collections.items() + ] + ) + for collection, data in related_models.items(): + export.setdefault(collection, {}).update( + {str(id_): instance_data for id_, instance_data in data.items()} + ) + # Sort instances by id within each collection for collection, instances in export.items(): if collection == "_migration_index": @@ -290,15 +312,9 @@ def add_users( gender_dict = datastore.get_all("gender", ["name"], lock_result=False) user["gender"] = gender_dict.get(gender_id, {}).get("name") # limit user fields to exported objects - collection_field_tupels = [ - ("meeting_user", "meeting_user_ids"), - ("poll", "poll_voted_ids"), - ("option", "option_ids"), - ("vote", "vote_ids"), - ("poll_candidate", "poll_candidate_ids"), - ("vote", "delegated_vote_ids"), - ] - for collection, fname in collection_field_tupels: + # Check voting related fields for mu + collection_field_tuples = [("meeting_user", "meeting_user_ids")] + for collection, fname in collection_field_tuples: user[fname] = [ id_ for id_ in user.get(fname, []) @@ -332,11 +348,8 @@ def get_relation_fields() -> Iterable[RelationListField]: isinstance(field, RelationListField) and field not in HISTORY_FIELDS_PER_COLLECTION["meeting"] and ( - ( - field.on_delete == OnDelete.CASCADE - and field.get_own_field_name().endswith("_ids") - ) - or field.get_own_field_name() in NON_CASCADING_MEETING_RELATION_LISTS + field.on_delete == OnDelete.CASCADE + and field.get_own_field_name().endswith("_ids") ) ): yield field diff --git a/openslides_backend/shared/history_events.py b/openslides_backend/shared/history_events.py index 1f0eaef75c..8f88a6404e 100644 --- a/openslides_backend/shared/history_events.py +++ b/openslides_backend/shared/history_events.py @@ -2,13 +2,60 @@ from typing import Any from zoneinfo import ZoneInfo +from psycopg.types.json import Jsonb + from .interfaces.event import ListFields from .patterns import FullQualifiedId, fqid_from_collection_and_id -from .typing import HistoryInformation +from .typing import HistoryInformation, HistoryInformationData EventPayload = tuple[FullQualifiedId, dict[str, Any] | ListFields] +def build_history_information_data( + entries: list[str] | None = None, + structured_information: dict[str, Any] | None = None, +) -> HistoryInformationData: + data: HistoryInformationData = {} + if entries is not None: + data["entries"] = entries + if structured_information is not None: + data["structured_information"] = structured_information + return data + + +def update_history_information( + information: HistoryInformation, + fqid: FullQualifiedId, + entries: list[str] | None = None, + structured_information: dict[str, Any] | None = None, +) -> None: + """Updates history information for fqid""" + if fqid not in information: + information[fqid] = build_history_information_data( + entries, structured_information + ) + else: + if entries: + information[fqid].setdefault("entries", list()).extend(entries) + if structured_information: + information[fqid].setdefault("structured_information", dict()).update( + structured_information + ) + + +def update_history_information_multi( + information: HistoryInformation, + fqids: list[FullQualifiedId], + entries: list[str] | None = None, + structured_information: dict[str, Any] | None = None, +) -> None: + """ + Adds given HistoryInformation to the given information for every fqid in fqids. + """ + for fqid in fqids: + update_history_information(information, fqid, entries, structured_information) + + def calculate_history_event_payloads( user_id: int | None, information: HistoryInformation, @@ -19,8 +66,13 @@ def calculate_history_event_payloads( timestamp: int | None = None, ) -> list[EventPayload]: transformed_information = [ - (model_fqid_to_entry_id[fqid], fqid, entries) - for fqid, entries in information.items() + ( + model_fqid_to_entry_id[fqid], + fqid, + data.get("entries") or None, + data.get("structured_information"), + ) + for fqid, data in information.items() ] create_events: list[EventPayload] = [ ( @@ -28,13 +80,16 @@ def calculate_history_event_payloads( { "id": id_, "entries": entries, + "structured_information": ( + Jsonb(structured_information) if structured_information else None + ), "position_id": position_id, "original_model_id": fqid, "model_id": (fqid if fqid in existing_fqids else None), "meeting_id": model_fqid_to_meeting_id.get(fqid, None), }, ) - for id_, fqid, entries in transformed_information + for id_, fqid, entries, structured_information in transformed_information ] create_events.append( ( diff --git a/openslides_backend/shared/interfaces/services.py b/openslides_backend/shared/interfaces/services.py index 44ba738560..b3746fe31f 100644 --- a/openslides_backend/shared/interfaces/services.py +++ b/openslides_backend/shared/interfaces/services.py @@ -3,7 +3,6 @@ from ...services.auth.interface import AuthenticationService from ...services.database.interface import Database from ...services.media.interface import MediaService -from ...services.vote.interface import VoteService class Services(Protocol): @@ -16,5 +15,3 @@ def authentication(self) -> AuthenticationService: ... def datastore(self) -> Database: ... def media(self) -> MediaService: ... - - def vote(self) -> VoteService: ... diff --git a/openslides_backend/shared/patterns.py b/openslides_backend/shared/patterns.py index 3519e27cd3..6687622ede 100644 --- a/openslides_backend/shared/patterns.py +++ b/openslides_backend/shared/patterns.py @@ -137,7 +137,7 @@ def collection_and_id_from_fqid(fqid: str) -> tuple[str, int]: # Build FQIDs -def fqid_from_collection_and_id(collection: str, id: str | int) -> str: +def fqid_from_collection_and_id(collection: str, id: str | int) -> FullQualifiedId: return f"{collection}{KEYSEPARATOR}{id}" diff --git a/openslides_backend/shared/typing.py b/openslides_backend/shared/typing.py index fbba186e7b..e8d2b03c3f 100644 --- a/openslides_backend/shared/typing.py +++ b/openslides_backend/shared/typing.py @@ -1,6 +1,6 @@ -from typing import Any, Union +from typing import Any, NotRequired, TypedDict, Union -from .patterns import Collection, Id +from .patterns import Collection, Field, FullQualifiedId, Id PartialModel = dict[str, Any] Model = dict[str, Any] @@ -8,7 +8,13 @@ Schema = dict[str, Any] -HistoryInformation = dict[str, list[str]] + +class HistoryInformationData(TypedDict): + entries: NotRequired[list[str]] + structured_information: NotRequired[dict[Field, Any]] + + +HistoryInformation = dict[FullQualifiedId, HistoryInformationData] JSON = Union[str, int, float, bool, None, dict[str, Any], list[Any]] diff --git a/openslides_backend/wsgi.py b/openslides_backend/wsgi.py index bf80c76022..2814efbddc 100644 --- a/openslides_backend/wsgi.py +++ b/openslides_backend/wsgi.py @@ -6,7 +6,6 @@ from .http.views import ActionView, PresenterView from .services.auth.adapter import AuthenticationHTTPAdapter from .services.media.adapter import MediaServiceAdapter -from .services.vote.adapter import VoteAdapter from .shared.interfaces.logging import LoggingModule from .shared.interfaces.wsgi import View, WSGIApplication @@ -21,7 +20,6 @@ class OpenSlidesBackendServices(containers.DeclarativeContainer): env = providers.Object(0) authentication = providers.Singleton(AuthenticationHTTPAdapter, logging) media = providers.Singleton(MediaServiceAdapter, config.media_url, logging) - vote = providers.Singleton(VoteAdapter, config.vote_url, logging) class OpenSlidesBackendWSGI(containers.DeclarativeContainer): diff --git a/tests/conftest.py b/tests/conftest.py index 8c64861f67..6d3f5f25c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ def get_rel_db_table_names(curs: Cursor[DictRow]) -> list[str]: gets the table names of the relational schema currently applied to the database """ rows = curs.execute( - "SELECT schemaname, tablename from pg_tables where schemaname in ('public', 'vote');" + "SELECT schemaname, tablename from pg_tables where schemaname in ('public', 'ballot');" ).fetchall() return [ f"{row.get('schemaname', '')}.{row.get('tablename', '')}" diff --git a/tests/database/reader/system/test_filter.py b/tests/database/reader/system/test_filter.py index 1d466ce299..624e39b42f 100644 --- a/tests/database/reader/system/test_filter.py +++ b/tests/database/reader/system/test_filter.py @@ -176,10 +176,13 @@ def test_types_str_list(db_connection: Connection) -> None: 1: { "id": 1, "entries": ["User added to meetings"], + "structured_information": None, "meeting_id": 1, "model_id": "user/1", "model_id_assignment_id": None, + "model_id_meeting_user_id": None, "model_id_motion_id": None, + "model_id_poll_id": None, "model_id_user_id": 1, "original_model_id": None, "position_id": 1, @@ -235,10 +238,7 @@ def test_types_enum_list(db_connection: Connection) -> None: "poll_ids": None, "read_chat_group_ids": None, "read_comment_section_ids": None, - "used_as_assignment_poll_default_id": None, - "used_as_motion_poll_default_id": None, - "used_as_poll_default_id": None, - "used_as_topic_poll_default_id": None, + "used_in_meeting_poll_default_ids": None, "weight": None, "write_chat_group_ids": None, "write_comment_section_ids": None, diff --git a/tests/database/reader/system/util.py b/tests/database/reader/system/util.py index 7170f09701..faf0a48b72 100644 --- a/tests/database/reader/system/util.py +++ b/tests/database/reader/system/util.py @@ -78,13 +78,8 @@ "meeting_ids": None, "is_present_in_meeting_ids": None, "meeting_user_ids": None, - "option_ids": None, - "poll_candidate_ids": None, - "poll_voted_ids": None, - "vote_ids": None, "committee_ids": None, "committee_management_ids": None, - "delegated_vote_ids": None, "organization_id": 1, }, 2: { @@ -115,13 +110,8 @@ "meeting_ids": None, "is_present_in_meeting_ids": None, "meeting_user_ids": None, - "option_ids": None, - "poll_candidate_ids": None, - "poll_voted_ids": None, - "vote_ids": None, "committee_ids": None, "committee_management_ids": None, - "delegated_vote_ids": None, "organization_id": 1, }, 3: { @@ -152,13 +142,8 @@ "meeting_ids": None, "is_present_in_meeting_ids": None, "meeting_user_ids": None, - "option_ids": None, - "poll_candidate_ids": None, - "poll_voted_ids": None, - "vote_ids": None, "committee_ids": None, "committee_management_ids": None, - "delegated_vote_ids": None, "organization_id": 1, }, }, @@ -236,7 +221,6 @@ "meeting.can_see_history", "motion.can_manage", "motion.can_manage_polls", - "poll.can_manage", "projector.can_manage", "tag.can_manage", "user.can_manage", diff --git a/tests/database/writer/system/test_delete.py b/tests/database/writer/system/test_delete.py index 18d1e625b3..8eba82523d 100644 --- a/tests/database/writer/system/test_delete.py +++ b/tests/database/writer/system/test_delete.py @@ -314,7 +314,7 @@ def test_delete_1_n_not_null_success_replace_relations( "default_projector_countdown_ids": [2], "default_projector_assignment_poll_ids": [2], "default_projector_motion_poll_ids": [2], - "default_projector_poll_ids": [2], + "default_projector_topic_poll_ids": [2], "reference_projector_id": 2, }, ) @@ -335,7 +335,7 @@ def test_delete_1_n_not_null_success_replace_relations( "used_as_default_projector_for_countdown_in_meeting_id": 1, "used_as_default_projector_for_assignment_poll_in_meeting_id": 1, "used_as_default_projector_for_motion_poll_in_meeting_id": 1, - "used_as_default_projector_for_poll_in_meeting_id": 1, + "used_as_default_projector_for_topic_poll_in_meeting_id": 1, "used_as_reference_projector_meeting_id": 1, }, ) diff --git a/tests/system/action/agenda_item/test_forward.py b/tests/system/action/agenda_item/test_forward.py index 62e2181f95..c5a893ef8d 100644 --- a/tests/system/action/agenda_item/test_forward.py +++ b/tests/system/action/agenda_item/test_forward.py @@ -5,6 +5,7 @@ from psycopg.types.json import Jsonb from openslides_backend.action.actions.speaker.speech_state import SpeechState +from openslides_backend.models.models import Poll from openslides_backend.shared.util import ONE_ORGANIZATION_FQID, ONE_ORGANIZATION_ID from tests.system.action.base import BaseActionTestCase @@ -3291,19 +3292,25 @@ def test_forward_structure_level_transfer_only_via_meeting_users(self) -> None: "poll/1234": { "meeting_id": 1, "title": "Will not transfer", - "type": "pseudoanonymous", - "backend": "fast", - "pollmethod": "Y", - "state": "created", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - "sequential_number": 1, + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_rating_score/34", + "state": Poll.STATE_CREATED, "content_object_id": "topic/11", }, - "option/123": {"meeting_id": 1, "poll_id": 1234, "text": "Option A"}, - "option/234": {"meeting_id": 1, "poll_id": 1234, "text": "Option B"}, + "poll_config_rating_score/34": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "min_options_amount": 1, + "max_options_amount": 1, + "max_votes_per_option": 1, + }, + "poll_option/123": { + "poll_id": 1234, + "text": "Option A", + }, + "poll_option/234": { + "poll_id": 1234, + "text": "Option B", + }, # mediafiles "mediafile/3": { "create_timestamp": datetime.fromtimestamp(300, ZoneInfo("UTC")), @@ -3480,7 +3487,8 @@ def test_forward_structure_level_transfer_only_via_meeting_users(self) -> None: "mediafile": 4, "meeting_mediafile": 36, "poll": 1235, - "option": 235, + "poll_config_approval": 35, + "poll_option": 235, "speaker": 38, "structure_level_list_of_speakers": 15, "point_of_order_category": 8, diff --git a/tests/system/action/base.py b/tests/system/action/base.py index 6a138b3a1d..64efcd363d 100644 --- a/tests/system/action/base.py +++ b/tests/system/action/base.py @@ -15,6 +15,7 @@ from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permission from openslides_backend.services.database.commands import GetManyRequest +from openslides_backend.services.database.interface import PartialModel from openslides_backend.shared.exceptions import AuthenticationException from openslides_backend.shared.filters import FilterOperator from openslides_backend.shared.patterns import ( @@ -297,7 +298,9 @@ def base_locked_out_superadmin_permission_test( True, ) - def get_last_history_information(self, fqid: FullQualifiedId) -> list[str] | None: + def get_last_history_information( + self, fqid: FullQualifiedId + ) -> PartialModel | None: entry_id = self.datastore.max( "history_entry", FilterOperator("original_model_id", "=", fqid), @@ -305,37 +308,46 @@ def get_last_history_information(self, fqid: FullQualifiedId) -> list[str] | Non lock_result=False, ) if entry_id: - history_entry = self.datastore.get( + return self.datastore.get( fqid_from_collection_and_id("history_entry", entry_id), - ["entries"], + ["entries", "structured_information"], lock_result=False, ) - return history_entry.get("entries") else: return None def assert_history_information( - self, fqid: FullQualifiedId, information: list[str] | None + self, + fqid: FullQualifiedId, + entries: list[str] | None = None, + structured_information: dict[str, Any] | None = None, ) -> None: """ Asserts that the last history information for the given model is the given information. """ last_information = self.get_last_history_information(fqid) - if information is None: - assert not last_information + if entries is None and structured_information is None: + assert ( + not last_information + ), f"Expected no history information to be generated for {fqid}. Got:\n{last_information}" else: - assert last_information - self.assertEqual(last_information, information) + assert ( + last_information + ), f"No history information was be generated for {fqid}." + self.assertEqual(last_information.get("entries"), entries) + self.assertEqual( + last_information.get("structured_information"), structured_information + ) def assert_history_information_contains( - self, fqid: FullQualifiedId, information: str + self, fqid: FullQualifiedId, entry: str ) -> None: """ Asserts that the last history information for the given model is the given information. """ last_information = self.get_last_history_information(fqid) - assert last_information - assert information in last_information + assert last_information, f"No history information was be generated for {fqid}." + self.assertIn(entry, last_information["entries"]) def assert_logged_in(self) -> None: self.auth.authenticate() # assert that no exception is thrown diff --git a/tests/system/action/group/test_delete.py b/tests/system/action/group/test_delete.py index 67f1e67664..09f81c8d91 100644 --- a/tests/system/action/group/test_delete.py +++ b/tests/system/action/group/test_delete.py @@ -1,3 +1,4 @@ +from openslides_backend.models.models import Poll from openslides_backend.permissions.permissions import Permissions from tests.system.action.base import BaseActionTestCase @@ -59,6 +60,45 @@ def test_delete_with_users(self) -> None: response.json["message"], "You cannot delete a group with users." ) + def base_test_delete_with_poll(self, poll_state: str, fail: bool) -> None: + self.create_motion(22, 30) + self.set_models( + { + "poll/71": { + "meeting_id": 22, + "title": "Poll 71", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_approval/34", + "state": poll_state, + "content_object_id": "motion/30", + }, + "poll_config_approval/34": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "group/24": {"poll_ids": [71]}, + } + ) + response = self.request("group.delete", {"id": 24}) + + if fail: + self.assert_status_code(response, 400) + self.assertEqual( + response.json["message"], + "You cannot delete a group with a running poll.", + ) + else: + self.assert_status_code(response, 200) + self.assert_model_not_exists("group/24") + + def test_delete_with_poll_created(self) -> None: + self.base_test_delete_with_poll(Poll.STATE_CREATED, False) + + def test_delete_with_poll_started(self) -> None: + self.base_test_delete_with_poll(Poll.STATE_STARTED, True) + + def test_delete_with_poll_finished(self) -> None: + self.base_test_delete_with_poll(Poll.STATE_FINISHED, False) + def test_delete_no_permissions(self) -> None: self.base_permission_test( {"group/4": {"name": "name_srtgb123", "meeting_id": 1}}, diff --git a/tests/system/action/meeting/test_archive.py b/tests/system/action/meeting/test_archive.py index 6421b852a1..43e2f3b133 100644 --- a/tests/system/action/meeting/test_archive.py +++ b/tests/system/action/meeting/test_archive.py @@ -102,19 +102,21 @@ def test_archive_meeting_with_inactive_speakers(self) -> None: response = self.request("meeting.archive", {"id": 1}) self.assert_status_code(response, 200) - def create_poll(self, base: int, state: str) -> None: + def create_poll(self, base: int, state: str, published: bool = False) -> None: self.set_models( { f"poll/{base}": { "title": f"Poll {base}", - "type": Poll.TYPE_NAMED, - "backend": "fast", - "pollmethod": "YNA", - "onehundred_percent_base": "YNA", + "config_id": f"poll_config_approval/{base}", + "visibility": Poll.VISIBILITY_NAMED, "meeting_id": 1, "content_object_id": "motion/1", "state": state, - } + "published": published, + }, + f"poll_config_approval/{base}": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, } ) @@ -122,7 +124,7 @@ def test_archive_meeting_with_inactive_polls(self) -> None: self.create_motion(1) self.create_poll(1, Poll.STATE_CREATED) self.create_poll(2, Poll.STATE_FINISHED) - self.create_poll(3, Poll.STATE_PUBLISHED) + self.create_poll(3, Poll.STATE_FINISHED, True) response = self.request("meeting.archive", {"id": 1}) self.assert_status_code(response, 200) diff --git a/tests/system/action/meeting/test_clone.py b/tests/system/action/meeting/test_clone.py index dd10cf6475..9adb1061c0 100644 --- a/tests/system/action/meeting/test_clone.py +++ b/tests/system/action/meeting/test_clone.py @@ -9,7 +9,8 @@ from openslides_backend.action.action_worker import ActionWorkerState from openslides_backend.migrations.migration_helper import MigrationHelper from openslides_backend.models.checker import Checker, CheckException -from openslides_backend.models.models import AgendaItem, Meeting +from openslides_backend.models.mixins import MeetingModelMixin +from openslides_backend.models.models import AgendaItem, Meeting, Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permissions from openslides_backend.shared.export_helper import get_fields_for_export @@ -498,13 +499,6 @@ def test_clone_with_change_recommendation_complex(self) -> None: "motions_export_preamble": "an export preamble", "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": True, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_group_ids": [], - "motion_poll_default_backend": "fast", }, ) self.set_user_groups(1, [5]) @@ -831,16 +825,6 @@ def test_clone_with_personal_note(self) -> None: "personal_note/2", {"meeting_user_id": 2, "meeting_id": 2} ) - def test_clone_with_option(self) -> None: - self.set_test_data_with_admin() - self.set_models({"option/1": {"content_object_id": "user/1", "meeting_id": 1}}) - response = self.request("meeting.clone", {"meeting_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists("user/1", {"option_ids": [1, 2]}) - self.assert_model_exists( - "option/2", {"content_object_id": "user/1", "meeting_id": 2} - ) - def test_clone_with_mediafile(self) -> None: self.set_test_data_with_admin() self.set_models( @@ -1416,11 +1400,6 @@ def test_clone_with_settings(self) -> None: "motions_export_preamble": "pre", "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": True, - "motion_poll_ballot_paper_selection": "NUMBER_OF_DELEGATES", - "motion_poll_ballot_paper_number": 42, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YN", "users_enable_presence_view": True, "users_enable_vote_weight": True, "users_enable_vote_delegations": True, @@ -1436,15 +1415,41 @@ def test_clone_with_settings(self) -> None: "users_email_body": "body", "assignments_export_title": "title", "assignments_export_preamble": "pre", - "assignment_poll_ballot_paper_selection": "NUMBER_OF_DELEGATES", - "assignment_poll_ballot_paper_number": 42, + "assignment_poll_default_method": "rating_approval", "assignment_poll_add_candidates_to_list_of_speakers": True, - "assignment_poll_enable_max_votes_per_option": False, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "YNA", - "assignment_poll_default_onehundred_percent_base": "YNA", + "poll_enable_max_votes_per_option": False, + } + assignment_poll_settings = { + "sort_result_by_votes": True, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": True, } + motion_poll_settings = { + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": False, + } + topic_poll_settings = { + "sort_result_by_votes": True, + "visibility": "named", + "onehundred_percent_base": "valid", + "display_chart": "pie", + } + self.test_models.update( + { + "meeting/1": { + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + }, + "group/2": {"used_in_meeting_poll_default_ids": [1, 2]}, + "group/3": {"used_in_meeting_poll_default_ids": [1, 2, 3]}, + "meeting_poll_default/1": {"meeting_id": 1, **assignment_poll_settings}, + "meeting_poll_default/2": {"meeting_id": 1, **motion_poll_settings}, + "meeting_poll_default/3": {"meeting_id": 1, **topic_poll_settings}, + } + ) self.meeting_data.update(settings) self.set_test_data_with_admin() response = self.request("meeting.clone", {"meeting_id": 1}) @@ -1452,7 +1457,49 @@ def test_clone_with_settings(self) -> None: settings["name"] = f"{settings['name']} - Copy" settings["start_time"] = timestamp_with_tz settings["end_time"] = timestamp_with_tz - self.assert_model_exists("meeting/2", settings) + self.assert_model_exists( + "meeting/2", + { + **settings, + "assignment_poll_config_id": 4, + "motion_poll_config_id": 5, + "topic_poll_config_id": 6, + "poll_default_ids": [4, 5, 6], + }, + ) + self.assert_model_exists( + "meeting_poll_default/4", + { + "meeting_id": 2, + "used_as_assignment_poll_config_in_meeting_id": 2, + "group_ids": [5, 6], + **assignment_poll_settings, + }, + ) + self.assert_model_exists( + "meeting_poll_default/5", + { + "meeting_id": 2, + "used_as_motion_poll_config_in_meeting_id": 2, + "group_ids": [5, 6], + **motion_poll_settings, + }, + ) + self.assert_model_exists( + "meeting_poll_default/6", + { + "meeting_id": 2, + "used_as_topic_poll_config_in_meeting_id": 2, + "group_ids": [6], + **topic_poll_settings, + }, + ) + self.assert_model_exists( + "group/5", {"meeting_id": 2, "used_in_meeting_poll_default_ids": [4, 5]} + ) + self.assert_model_exists( + "group/6", {"meeting_id": 2, "used_in_meeting_poll_default_ids": [4, 5, 6]} + ) def test_limit_of_meetings_error(self) -> None: self.set_test_data_with_admin() @@ -1726,29 +1773,22 @@ def test_clone_with_forwarded_motion(self) -> None: }, "poll/1": { "title": "a", - "type": "pseudoanonymous", - "backend": "fast", - "pollmethod": "YNA", - "state": "published", - "onehundred_percent_base": "valid", - "votesvalid": "1.000000", - "votescast": "1.000000", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_approval/1", + "state": Poll.STATE_FINISHED, + "published": True, "content_object_id": "motion/1", - "entitled_group_ids": [3], "meeting_id": 1, }, - "option/2": { - "weight": 0, - "poll_id": 1, - "content_object_id": "motion/1", - "meeting_id": 1, + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "allow_abstain": True, }, - "vote/3": { + "group/3": {"poll_ids": [1]}, + "poll_ballot/3": { "weight": "0.000000", "value": "Y", - "user_token": "ABC", - "option_id": 2, - "meeting_id": 1, + "poll_id": 1, }, "motion/2": { "meeting_id": 4, @@ -1811,13 +1851,11 @@ def test_clone_with_forwarded_motion(self) -> None: {"comment": "", "motion_id": 5, "section_id": 3, "meeting_id": 5}, ) self.assert_model_exists( - "vote/4", + "poll_ballot/4", { "weight": Decimal("0.000000"), "value": "Y", - "user_token": "ABC", - "option_id": 3, - "meeting_id": 5, + "poll_id": 2, }, ) @@ -1922,49 +1960,63 @@ def test_clone_vote_delegation(self) -> None: ) def test_clone_vote_delegated_vote(self) -> None: - self.set_test_data_with_admin() + self.set_test_data() self.create_meeting(4) self.set_user_groups(1, [2, 5]) - self.set_models( - { - "vote/1": { - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 1, - "option_id": 1, - "user_token": "asdfgh", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "vote/2": { - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 4, - "option_id": 2, - "user_token": "hjkl", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "option/1": {"meeting_id": 1}, - "option/2": {"meeting_id": 4}, - }, - ) + for poll_id in [1, 4]: + self.create_motion(poll_id, poll_id) + self.set_models( + { + f"poll/{poll_id}": { + "title": f"Poll {poll_id}", + "meeting_id": poll_id, + "content_object_id": f"motion/{poll_id}", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": f"poll_config_rating_approval/{poll_id}", + "state": Poll.STATE_STARTED, + }, + f"poll_config_rating_approval/{poll_id}": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + f"poll_ballot/{poll_id}": { + "poll_id": poll_id, + "poll_ballot_user_id": poll_id, + }, + f"poll_ballot_user/{poll_id}": { + "acting_meeting_user_id": 1 if poll_id == 1 else 2, + "represented_meeting_user_id": 1 if poll_id == 1 else 2, + "poll_id": poll_id, + }, + }, + ) response = self.request("meeting.clone", {"meeting_id": 1}) self.assert_status_code(response, 200) self.assert_model_exists( - "vote/3", - {"user_id": 1, "delegated_user_id": 1, "option_id": 3, "meeting_id": 5}, + "poll_ballot/5", + {"poll_id": 5, "poll_ballot_user_id": 5}, + ) + self.assert_model_exists( + "poll_ballot_user/5", + { + "acting_meeting_user_id": 3, + "represented_meeting_user_id": 3, + "poll_id": 5, + "poll_ballot_id": 5, + }, ) self.assert_model_exists( "user/1", + {"meeting_user_ids": [1, 2, 3], "meeting_ids": [1, 4, 5]}, + ) + self.assert_model_exists( + "meeting_user/3", { - "meeting_user_ids": [1, 2, 3], - "vote_ids": [1, 2, 3], - "delegated_vote_ids": [1, 2, 3], - "meeting_ids": [1, 4, 5], + "user_id": 1, + "meeting_id": 5, + "acting_ballot_ids": [5], + "represented_ballot_ids": [5], }, ) - self.assert_model_exists("meeting_user/3", {"user_id": 1, "meeting_id": 5}) def test_with_action_worker(self) -> None: """action_worker shouldn't be cloned""" @@ -2177,74 +2229,69 @@ def test_clone_with_list_election(self) -> None: "open_posts": 0, "list_of_speakers_id": 1, }, - "poll_candidate/1": { - "id": 1, - "weight": 1, - "user_id": 2, - "meeting_id": 1, - "poll_candidate_list_id": 1, - }, - "poll_candidate/2": { - "id": 2, - "weight": 2, - "user_id": 3, - "meeting_id": 1, - "poll_candidate_list_id": 1, - }, - "poll_candidate/3": { - "id": 3, - "weight": 3, - "user_id": 4, - "meeting_id": 1, - "poll_candidate_list_id": 1, - }, - "poll_candidate_list/1": { - "id": 1, - "option_id": 1, - "meeting_id": 1, - "poll_candidate_ids": [1, 2, 3], - }, - "option/1": { - "id": 1, - "weight": 1, - "poll_id": 1, - "meeting_id": 1, - "content_object_id": "poll_candidate_list/1", - }, - "option/2": { - "id": 2, - "text": "global option", - "weight": 1, - "meeting_id": 1, - "used_as_global_option_in_poll_id": 1, - }, "poll/1": { "id": 1, - "type": "pseudoanonymous", - "state": "created", "title": "First election", - "backend": "fast", - "global_no": False, - "votescast": "0.000000", - "global_yes": False, "meeting_id": 1, - "option_ids": [1], - "pollmethod": "YNA", - "votesvalid": "0.000000", - "votesinvalid": "0.000000", - "global_abstain": False, - "global_option_id": 2, - "max_votes_amount": 1, - "min_votes_amount": 1, "content_object_id": "assignment/1", - "is_pseudoanonymized": True, - "max_votes_per_option": 1, - "onehundred_percent_base": "disabled", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_approval/1", + "state": Poll.STATE_CREATED, + }, + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + **{ + f"poll_option/{id_}": { + "id": id_, + "weight": id_, + "meeting_user_id": id_ + 1, + "poll_id": 1, + } + for id_ in range(1, 4) }, } ) + response = self.request("meeting.clone", {"meeting_id": 1}) self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting/2", + { + "committee_id": 60, + "list_of_speakers_ids": [2], + "assignment_ids": [2], + "poll_ids": [2], + "projector_ids": [2], + "reference_projector_id": 2, + **{key: [2] for key in MeetingModelMixin.all_default_projectors()}, + "motions_default_amendment_workflow_id": 2, + }, + ) + self.assert_model_exists( + "poll/2", + { + "title": "First election", + "meeting_id": 2, + "content_object_id": "assignment/2", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_approval/2", + "state": Poll.STATE_CREATED, + }, + ) + self.assert_model_exists( + "poll_config_approval/2", + {"onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID}, + ) + for id_ in range(4, 7): + self.assert_model_exists( + f"poll_option/{id_}", + { + "weight": id_ - 3, + "poll_id": 2, + "meeting_user_id": id_ + 2, + }, + ) def test_clone_with_structured_published_orga_files(self) -> None: self.set_test_data_with_admin() diff --git a/tests/system/action/meeting/test_create.py b/tests/system/action/meeting/test_create.py index 2817620a28..02b87c4e78 100644 --- a/tests/system/action/meeting/test_create.py +++ b/tests/system/action/meeting/test_create.py @@ -4,7 +4,7 @@ from openslides_backend.i18n.translator import Translator from openslides_backend.i18n.translator import translate as _ -from openslides_backend.models.models import Meeting +from openslides_backend.models.models import Meeting, Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.shared.util import ONE_ORGANIZATION_FQID from tests.system.action.base import BaseActionTestCase @@ -70,18 +70,22 @@ def test_create_simple_and_complex_workflow(self) -> None: "projector_countdown_warning_time": 0, "organization_tag_ids": [3], "is_active_in_organization_id": 1, - "assignment_poll_default_group_ids": [3], - "motion_poll_default_group_ids": [3], - "topic_poll_default_group_ids": [3], - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_default_ids": [1, 2, 3], + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, **{field: [1] for field in Meeting.all_default_projectors()}, }, ) self.assert_model_exists(ONE_ORGANIZATION_FQID, {"active_meeting_ids": [1]}) self.assert_model_exists("group/1", {"name": "Default"}) self.assert_model_exists("group/2", {"name": "Admin"}) - self.assert_model_exists("group/3", {"name": "Delegates"}) + self.assert_model_exists( + "group/3", + {"name": "Delegates", "used_in_meeting_poll_default_ids": [1, 2, 3]}, + ) self.assert_model_exists("group/4", {"name": "Staff"}) self.assert_model_exists( "motion_workflow/1", @@ -185,9 +189,60 @@ def test_create_simple_and_complex_workflow(self) -> None: "countdown_time": 60, }, ) + self.assert_model_exists( + "meeting_poll_default/1", + { + "meeting_id": 1, + "used_as_assignment_poll_config_in_meeting_id": 1, + "used_as_motion_poll_config_in_meeting_id": None, + "used_as_topic_poll_config_in_meeting_id": None, + "group_ids": [3], + "sort_result_by_votes": True, + "visibility": Poll.VISIBILITY_SECRET, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": "valid", + "display_chart": None, + }, + ) + self.assert_model_exists( + "meeting_poll_default/2", + { + "meeting_id": 1, + "used_as_assignment_poll_config_in_meeting_id": None, + "used_as_motion_poll_config_in_meeting_id": 1, + "used_as_topic_poll_config_in_meeting_id": None, + "group_ids": [3], + "sort_result_by_votes": True, + "visibility": Poll.VISIBILITY_SECRET, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": "valid", + "display_chart": None, + }, + ) + self.assert_model_exists( + "meeting_poll_default/3", + { + "meeting_id": 1, + "used_as_assignment_poll_config_in_meeting_id": None, + "used_as_motion_poll_config_in_meeting_id": None, + "used_as_topic_poll_config_in_meeting_id": 1, + "group_ids": [3], + "sort_result_by_votes": True, + "visibility": Poll.VISIBILITY_MANUALLY, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": "valid", + "display_chart": "pie", + }, + ) def test_check_action_data_fields(self) -> None: - """Also checks defaults for assignment_poll and motion_poll.""" + """Also checks defaults for polls.""" external_id = "external" self.basic_test( { @@ -206,10 +261,11 @@ def test_check_action_data_fields(self) -> None: "start_time": datetime.fromtimestamp(1608120653, ZoneInfo("UTC")), "end_time": datetime.fromtimestamp(1608121653, ZoneInfo("UTC")), "external_id": external_id, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", + "assignment_poll_default_method": Poll.METHOD_SELECTION, + "topic_poll_default_method": Poll.METHOD_SELECTION, + "poll_default_live_voting_enabled": False, + "poll_default_allow_invalid": False, + "poll_default_allow_vote_split": False, }, ) diff --git a/tests/system/action/meeting/test_delete.py b/tests/system/action/meeting/test_delete.py index a0f4fb3496..6da7356058 100644 --- a/tests/system/action/meeting/test_delete.py +++ b/tests/system/action/meeting/test_delete.py @@ -3,6 +3,7 @@ from psycopg.types.json import Jsonb +from openslides_backend.models.models import Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permissions from openslides_backend.shared.util import ONE_ORGANIZATION_FQID @@ -246,30 +247,21 @@ def test_delete_with_poll_candidates_and_speakers(self) -> None: "meeting_id": 1, "content_object_id": "assignment/140", "title": "Analog poll 150", - "type": "analog", - "pollmethod": "YNA", + "state": Poll.STATE_CREATED, + "config_id": "poll_config_approval/160", + "visibility": Poll.VISIBILITY_MANUALLY, "meeting_id": 1, }, - "option/160": { - "meeting_id": 1, - "poll_id": 150, - "content_object_id": "poll_candidate_list/170", - }, - "poll_candidate_list/170": { - "meeting_id": 1, - "option_id": 160, + "poll_config_approval/160": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID }, - "poll_candidate/180": { - "meeting_id": 1, - "weight": 1, - "poll_candidate_list_id": 170, - "user_id": 2, + "poll_option/180": { + "poll_id": 150, + "meeting_user_id": 1, }, - "poll_candidate/181": { - "meeting_id": 1, - "weight": 1, - "poll_candidate_list_id": 170, - "user_id": 3, + "poll_option/181": { + "poll_id": 150, + "meeting_user_id": 2, }, "list_of_speakers/190": { "meeting_id": 1, @@ -307,10 +299,9 @@ def test_delete_with_poll_candidates_and_speakers(self) -> None: "group/3", "assignment/140", "poll/150", - "option/160", - "poll_candidate_list/170", - "poll_candidate/180", - "poll_candidate/181", + "poll_config_approval/160", + "poll_option/180", + "poll_option/181", "list_of_speakers/190", "speaker/210", "speaker/211", @@ -446,12 +437,21 @@ def test_delete_full_meeting(self) -> None: self.assert_model_not_exists(f"motion_state/{i+1}") for i in range(2): self.assert_model_not_exists(f"motion_workflow/{i+1}") - for i in range(5): + for i in range(3): + self.assert_model_not_exists(f"meeting_poll_default/{i+1}") + for i in range(6): self.assert_model_not_exists(f"poll/{i+1}") - for i in range(13): - self.assert_model_not_exists(f"option/{i+1}") + for i in range(10): + self.assert_model_not_exists(f"poll_option/{i+1}") for i in range(9): - self.assert_model_not_exists(f"vote/{i+1}") + self.assert_model_not_exists(f"poll_ballot/{i+1}") + self.assert_model_not_exists("poll_ballot_user/1") + for i in range(2): + self.assert_model_not_exists(f"poll_config_approval/{i+1}") + self.assert_model_not_exists("poll_config_selection/1") + self.assert_model_not_exists("poll_config_rating_score/1") + self.assert_model_not_exists("poll_config_rating_approval/1") + self.assert_model_not_exists("poll_config_stv_scottish/1") for i in range(2): self.assert_model_not_exists(f"assignment/{i+1}") for i in range(5): diff --git a/tests/system/action/meeting/test_import.py b/tests/system/action/meeting/test_import.py index 863c6a2203..7a280e9b84 100644 --- a/tests/system/action/meeting/test_import.py +++ b/tests/system/action/meeting/test_import.py @@ -1,7 +1,6 @@ import base64 import time from copy import deepcopy -from decimal import Decimal from typing import Any from psycopg.types.json import Jsonb @@ -9,7 +8,7 @@ from openslides_backend.action.action_worker import ActionWorkerState from openslides_backend.http.views.presenter_view import PresenterView from openslides_backend.migrations.migration_helper import MigrationHelper -from openslides_backend.models.models import Meeting +from openslides_backend.models.models import Meeting, Poll from openslides_backend.shared.util import ONE_ORGANIZATION_FQID, get_initial_data_file from tests.system.action.base import BaseActionTestCase from tests.system.util import ( @@ -138,13 +137,6 @@ def create_request_data( "motions_export_preamble": "an export preamble", "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": True, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_group_ids": [], - "motion_poll_default_backend": "fast", "users_enable_presence_view": True, "users_enable_vote_weight": True, "users_enable_vote_delegations": True, @@ -160,25 +152,10 @@ def create_request_data( "users_email_body": "Dear {name},\n\nthis is your personal OpenSlides login:\n\n{url}\nUsername: {username}\nPassword: {password}\n\n\nThis email was generated automatically.", "assignments_export_title": "Elections", "assignments_export_preamble": "", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": True, - "assignment_poll_enable_max_votes_per_option": None, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "votes", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_group_ids": [], - "assignment_poll_default_backend": "fast", - "poll_ballot_paper_selection": "CUSTOM_NUMBER", - "poll_ballot_paper_number": 8, - "poll_sort_poll_result_by_votes": True, - "poll_default_type": "pseudoanonymous", - "poll_default_method": "votes", - "poll_default_onehundred_percent_base": "valid", - "poll_default_group_ids": [], - "poll_default_backend": "fast", + "poll_enable_max_votes_per_option": None, "poll_default_live_voting_enabled": False, + "poll_default_allow_invalid": False, "poll_couple_countdown": True, "projector_ids": [1], "all_projection_ids": [], @@ -201,8 +178,6 @@ def create_request_data( "motion_workflow_ids": [1], "motion_change_recommendation_ids": [], "poll_ids": [], - "option_ids": [], - "vote_ids": [], "assignment_ids": [], "assignment_candidate_ids": [], "personal_note_ids": [], @@ -323,7 +298,11 @@ def create_request_data( if collection not in data["meeting"]: data["meeting"][collection] = models else: - data["meeting"][collection].update(models) + for id_, model in models.items(): + if id_ in data["meeting"][collection]: + data["meeting"][collection][id_].update(model) + else: + data["meeting"][collection][id_] = model return data @@ -369,9 +348,7 @@ def get_group_data(self, obj_id: int, data: dict[str, Any] = {}) -> dict[str, An "read_chat_group_ids": [], "write_chat_group_ids": [], "poll_ids": [], - "used_as_motion_poll_default_id": None, - "used_as_assignment_poll_default_id": None, - "used_as_poll_default_id": None, + "used_in_meeting_poll_default_ids": [], **data, } @@ -435,6 +412,44 @@ def get_meeting_mediafile_data( **data, } + def get_assignment_poll_data(self, obj_id: int) -> dict[str, Any]: + return { + "poll": { + str(obj_id): { + "id": obj_id, + "title": "pull", + "config_id": f"poll_config_rating_approval/{obj_id + 1}", + "visibility": Poll.VISIBILITY_MANUALLY, + "state": Poll.STATE_STARTED, + "meeting_id": 1, + "content_object_id": f"assignment/{obj_id + 2}", + } + }, + "poll_config_rating_approval": { + str(obj_id + 1): { + "id": obj_id + 1, + "poll_id": obj_id, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + } + }, + "assignment": { + str(obj_id + 2): { + "id": obj_id + 2, + "title": "just do it", + "meeting_id": 1, + "list_of_speakers_id": obj_id + 3, + "poll_ids": [obj_id], + } + }, + "list_of_speakers": { + str(obj_id + 3): { + "id": obj_id + 3, + "content_object_id": f"assignment/{obj_id + 2}", + "meeting_id": 1, + } + }, + } + def replace_migrated_projector_fields(self, data: dict[str, Any]) -> None: data["meeting"]["meeting"]["1"][ "default_projector_current_list_of_speakers_ids" @@ -875,6 +890,65 @@ def test_check_usernames_new_and_twice(self) -> None: ) self.assert_model_not_exists("user/3") + def test_with_poll_default_collections(self) -> None: + poll_defaults_data: dict[str, dict[str, Any]] = { + "meeting": { + "1": { + "poll_default_ids": [1, 2, 3], + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + } + }, + "group": {"2": {"used_in_meeting_poll_default_ids": [1, 2, 3]}}, + "meeting_poll_default": { + str(id_): { + "id": id_, + f"used_as_{poll_type}_poll_config_in_meeting_id": 1, + "meeting_id": 1, + "sort_result_by_votes": False, + "visibility": "named", + "allow_abstain": False, + "allow_nota": True, + "strike_out": True, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_CAST, + "group_ids": [2], + "display_chart": "test", + } + for poll_type, id_ in { + "assignment": 1, + "motion": 2, + "topic": 3, + }.items() + }, + } + request_data = self.create_request_data(poll_defaults_data) + response = self.request("meeting.import", request_data) + self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting/2", + { + "poll_default_ids": [1, 2, 3], + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + }, + ) + self.assert_model_exists( + "group/5", + {"used_in_meeting_poll_default_ids": [1, 2, 3]}, + ) + for poll_type, id_ in {"assignment": 1, "motion": 2, "topic": 3}.items(): + self.assert_model_exists( + f"meeting_poll_default/{id_}", + { + **poll_defaults_data["meeting_poll_default"][str(id_)], + "meeting_id": 2, + f"used_as_{poll_type}_poll_config_in_meeting_id": 2, + "group_ids": [5], + }, + ) + def test_check_negative_default_vote_weight(self) -> None: request_data = self.create_request_data({}) request_data["meeting"]["user"]["1"] = self.get_user_data( @@ -2318,75 +2392,135 @@ def test_import_with_wrong_decimal(self) -> None: in response.json["message"] ) - def test_import_new_user_with_vote(self) -> None: - self.set_models( + def prepare_user_with_ballot_data(self) -> dict[str, dict[str, Any]]: + data = self.create_request_data( { - "vote/1": { - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 1, - "option_id": 10, - "user_token": "asdfgh", - "weight": Decimal("1.000000"), - "value": "Y", + **self.get_assignment_poll_data(3), + "poll_ballot": { + "7": {"id": 7, "poll_id": 3, "poll_ballot_user_id": 14}, }, - "option/10": { - "vote_ids": [1], - "meeting_id": 1, + "poll_ballot_user": { + "14": { + "id": 14, + "poll_id": 3, + "poll_ballot_id": 7, + "acting_meeting_user_id": 11, + "represented_meeting_user_id": 11, + }, }, - "user/1": { - "vote_ids": [1], - "delegated_vote_ids": [1], + "meeting": { + "1": { + "poll_ids": [3], + "assignment_ids": [5], + "list_of_speakers_ids": [6], + } + }, + "group": { + "2": { + "poll_ids": [3], + } + }, + "meeting_user": { + "11": { + "acting_ballot_ids": [14], + "represented_ballot_ids": [14], + } }, } ) - data = self.create_request_data( + data["meeting"]["poll"]["3"].update( { - "vote": { - "1": { - "id": 1, - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 1, - "option_id": 1, - "user_token": "asdfgh", - "weight": "1.000000", - "value": "Y", - }, + "entitled_group_ids": [2], + "ballot_ids": [7], + "ballot_user_ids": [14], + } + ) + return data + + def test_import_new_user_with_ballot(self) -> None: + self.set_user_groups(1, [1]) + self.create_motion(1, 30) + self.set_models( + { + "poll/1": { + "title": "pull", + "config_id": "poll_config_approval/1", + "visibility": Poll.VISIBILITY_MANUALLY, + "state": Poll.STATE_STARTED, + "meeting_id": 1, + "content_object_id": "motion/30", }, - "option": { - "1": { - "id": 1, - "vote_ids": [1], - "meeting_id": 1, - }, + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, + "poll_ballot/1": { + "poll_id": 1, + "poll_ballot_user_id": 1, + }, + "poll_ballot_user/1": { + "acting_meeting_user_id": 1, + "represented_meeting_user_id": 1, + "poll_id": 1, }, } ) - data["meeting"]["meeting"]["1"]["vote_ids"] = [1] - data["meeting"]["meeting"]["1"]["option_ids"] = [1] - data["meeting"]["user"]["1"]["vote_ids"] = [1] - data["meeting"]["user"]["1"]["delegated_vote_ids"] = [1] + + data = self.prepare_user_with_ballot_data() response = self.request("meeting.import", data) self.assert_status_code(response, 200) self.assert_model_exists( "user/1", { "username": "admin", - "meeting_user_ids": [2], - "vote_ids": [1], - "delegated_vote_ids": [1], + "meeting_user_ids": [1, 3], }, ) self.assert_model_exists( "user/2", { "username": "test", - "vote_ids": [2], - "delegated_vote_ids": [2], - "meeting_user_ids": [1], + "meeting_user_ids": [2], + }, + ) + self.assert_model_exists( + "meeting_user/1", + { + "user_id": 1, + "meeting_id": 1, + "acting_ballot_ids": [1], + "represented_ballot_ids": [1], + }, + ) + self.assert_model_exists( + "meeting_user/2", + { + "user_id": 2, + "meeting_id": 2, + "acting_ballot_ids": [2], + "represented_ballot_ids": [2], + }, + ) + self.assert_model_exists( + "meeting_user/3", + { + "user_id": 1, + "meeting_id": 2, + "acting_ballot_ids": None, + "represented_ballot_ids": None, + }, + ) + self.assert_model_exists( + "poll/2", + { + "config_id": "poll_config_rating_approval/1", + "content_object_id": "assignment/1", + "meeting_id": 2, + "ballot_ids": [2], + "ballot_user_ids": [2], + "entitled_group_ids": [5], }, ) + self.assert_model_exists("poll_config_rating_approval/1", {"poll_id": 2}) def test_gender_import(self) -> None: """ @@ -2525,67 +2659,19 @@ def test_gender_import(self) -> None: {"user_ids": [1, 2, 3, 4, 5, 6, 7], "gender_ids": [1, 4, 5, 6]}, ) - def test_import_existing_user_with_vote(self) -> None: - self.set_models( - { - "vote/1": { - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 1, - "option_id": 10, - "user_token": "asdfgh", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "option/10": { - "vote_ids": [1], - "meeting_id": 1, - }, - "user/1": { - "vote_ids": [1], - "delegated_vote_ids": [1], - }, - } - ) - data = self.create_request_data( - { - "vote": { - "1": { - "id": 1, - "user_id": 1, - "delegated_user_id": 1, - "meeting_id": 1, - "option_id": 1, - "user_token": "asdfgh", - "weight": "1.000000", - "value": "Y", - }, - }, - "option": { - "1": { - "id": 1, - "vote_ids": [1], - "meeting_id": 1, - }, - }, - } - ) - data["meeting"]["meeting"]["1"]["vote_ids"] = [1] - data["meeting"]["meeting"]["1"]["option_ids"] = [1] + def test_import_existing_user_with_ballot(self) -> None: + data = self.prepare_user_with_ballot_data() data["meeting"]["user"]["1"]["username"] = "admin" data["meeting"]["user"]["1"]["last_name"] = "" - data["meeting"]["user"]["1"]["vote_ids"] = [1] - data["meeting"]["user"]["1"]["delegated_vote_ids"] = [1] response = self.request("meeting.import", data) self.assert_status_code(response, 200) self.assert_model_exists( "user/1", - { - "username": "admin", - "meeting_user_ids": [1], - "vote_ids": [1, 2], - "delegated_vote_ids": [1, 2], - }, + {"username": "admin", "meeting_user_ids": [1]}, + ) + self.assert_model_exists( + "meeting_user/1", + {"user_id": 1, "acting_ballot_ids": [1], "represented_ballot_ids": [1]}, ) self.assert_model_not_exists("user/2") @@ -2729,14 +2815,8 @@ def test_import_export_with_orga_mediafiles(self) -> None: "motions_supporters_min_amount": 0, "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": False, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_backend": "fast", - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, "users_enable_presence_view": False, "users_enable_vote_weight": False, "users_enable_vote_delegations": True, @@ -2747,18 +2827,10 @@ def test_import_export_with_orga_mediafiles(self) -> None: "users_email_subject": "OpenSlides access data", "users_email_body": "blablabla", "assignments_export_title": "Elections", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": False, - "assignment_poll_enable_max_votes_per_option": False, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_backend": "fast", - "poll_default_type": "analog", - "poll_default_onehundred_percent_base": "YNA", - "poll_default_backend": "fast", + "poll_enable_max_votes_per_option": False, + "assignment_poll_default_method": Poll.METHOD_APPROVAL, + "topic_poll_default_method": Poll.METHOD_SELECTION, "poll_default_live_voting_enabled": False, "poll_couple_countdown": True, **{field: [1] for field in Meeting.all_default_projectors()}, @@ -3006,7 +3078,7 @@ def test_import_export_with_orga_mediafiles(self) -> None: } ) presenterapp = create_presenter_test_application() - presenterclient = Client(presenterapp, self.update_vote_service_auth_data) + presenterclient = Client(presenterapp) presenterclient.login("admin", "admin") self.auth_data = deepcopy(presenterclient.auth_data) response = presenterclient.post( diff --git a/tests/system/action/meeting/test_settings.py b/tests/system/action/meeting/test_settings.py index ef8d03f430..f07dde5591 100644 --- a/tests/system/action/meeting/test_settings.py +++ b/tests/system/action/meeting/test_settings.py @@ -1,3 +1,4 @@ +from openslides_backend.models.models import Poll from tests.system.action.base import BaseActionTestCase @@ -6,16 +7,130 @@ def setUp(self) -> None: super().setUp() self.create_meeting() - def test_group_ids(self) -> None: - self.set_models({"group/1": {"used_as_motion_poll_default_id": 1}}) + def test_update_poll_default_group_ids(self) -> None: + self.set_models( + { + "meeting/1": {"motion_poll_config_id": 1}, + "group/2": {"used_in_meeting_poll_default_ids": [1]}, + "meeting_poll_default/1": {"meeting_id": 1}, + } + ) response = self.request( "meeting.update", {"id": 1, "motion_poll_default_group_ids": [2, 3]} ) self.assert_status_code(response, 200) - self.assert_model_exists("meeting/1", {"motion_poll_default_group_ids": [2, 3]}) - self.assert_model_exists("group/1", {"used_as_motion_poll_default_id": None}) - self.assert_model_exists("group/2", {"used_as_motion_poll_default_id": 1}) - self.assert_model_exists("group/3", {"used_as_motion_poll_default_id": 1}) + self.assert_model_exists("group/1", {"used_in_meeting_poll_default_ids": None}) + self.assert_model_exists("group/2", {"used_in_meeting_poll_default_ids": [1]}) + self.assert_model_exists("group/3", {"used_in_meeting_poll_default_ids": [1]}) + self.assert_model_exists("meeting_poll_default/1", {"group_ids": [2, 3]}) + + def test_set_poll_default_group_ids_not_in_meeting(self) -> None: + self.create_meeting(4) + response = self.request( + "meeting.update", {"id": 1, "motion_poll_default_group_ids": [4]} + ) + self.assert_status_code(response, 400) + self.assertEqual( + "The following models do not belong to meeting 1: ['group/4']", + response.json["message"], + ) + self.assert_model_exists("meeting/1", {"poll_default_ids": None}) + + def test_poll_default_projector(self) -> None: + self.set_models({"projector/2": {"meeting_id": 1, "name": "Projector 2"}}) + response = self.request( + "meeting.update", {"id": 1, "default_projector_assignment_poll_ids": [2]} + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting/1", {"default_projector_assignment_poll_ids": [2]} + ) + self.assert_model_exists( + "projector/1", + {"used_as_default_projector_for_assignment_poll_in_meeting_id": None}, + ) + self.assert_model_exists( + "projector/2", + {"used_as_default_projector_for_assignment_poll_in_meeting_id": 1}, + ) + + def test_poll_default_projector_not_in_meeting(self) -> None: + self.create_meeting(4) + response = self.request( + "meeting.update", {"id": 1, "default_projector_assignment_poll_ids": [4]} + ) + self.assert_status_code(response, 400) + self.assertEqual( + "The following models do not belong to meeting 1: ['projector/4']", + response.json["message"], + ) + self.assert_model_exists( + "meeting/1", {"default_projector_assignment_poll_ids": [1]} + ) + + def base_test_meeting_poll_default_settings(self) -> None: + data = { + "sort_result_by_votes": False, + "visibility": "named", + "allow_abstain": False, + "allow_nota": True, + "strike_out": True, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_CAST, + "group_ids": [1], + "display_chart": "test", + } + response = self.request( + "meeting.update", + { + "id": 1, + **{ + f"{poll_type}_poll_default_{field_name}": value + for poll_type in ("assignment", "motion", "topic") + for field_name, value in data.items() + }, + }, + ) + self.assert_status_code(response, 200) + for poll_type, id_ in {"assignment": 1, "motion": 2, "topic": 3}.items(): + self.assert_model_exists( + f"meeting_poll_default/{id_}", + { + "meeting_id": 1, + f"used_as_{poll_type}_poll_config_in_meeting_id": 1, + **data, + }, + ) + self.assert_model_exists( + "group/1", + {"used_in_meeting_poll_default_ids": [1, 2, 3]}, + ) + + def test_create_meeting_poll_default_settings(self) -> None: + self.base_test_meeting_poll_default_settings() + self.assert_model_exists( + "meeting/1", + { + "poll_default_ids": [1, 2, 3], + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + }, + ) + + def test_update_meeting_poll_default_settings(self) -> None: + self.set_models( + { + "meeting/1": { + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + }, + "meeting_poll_default/1": {"meeting_id": 1}, + "meeting_poll_default/2": {"meeting_id": 1}, + "meeting_poll_default/3": {"meeting_id": 1}, + } + ) + self.base_test_meeting_poll_default_settings() def test_html_field_iframe(self) -> None: response = self.request( diff --git a/tests/system/action/meeting/test_update.py b/tests/system/action/meeting/test_update.py index 7ae8cb0734..59993a9adb 100644 --- a/tests/system/action/meeting/test_update.py +++ b/tests/system/action/meeting/test_update.py @@ -74,8 +74,8 @@ def test_update_some_fields_user_email(self) -> None: def test_update_motion_poll_projection(self) -> None: data = { - "motion_poll_projection_name_order_first": "first_name", - "motion_poll_projection_max_columns": 5, + "poll_projection_name_order_first": "first_name", + "poll_projection_max_columns": 5, } self.basic_test(data) self.assert_model_exists("meeting/1", data) @@ -83,12 +83,12 @@ def test_update_motion_poll_projection(self) -> None: def test_update_motion_poll_projection_invalid_data_error(self) -> None: response_message = self.basic_test( { - "motion_poll_projection_name_order_first": "best_name", + "poll_projection_name_order_first": "best_name", }, check_200=False, ) self.assertEqual( - "Action meeting.update: data.motion_poll_projection_name_order_first must be one of ['first_name', 'last_name']", + "Action meeting.update: data.poll_projection_name_order_first must be one of ['first_name', 'last_name']", response_message, ) @@ -288,15 +288,6 @@ def test_update_applause(self) -> None: self.basic_test(data) self.assert_model_exists("meeting/1", data) - def test_update_poll_default_backend_fields(self) -> None: - data = { - "motion_poll_default_backend": "long", - "assignment_poll_default_backend": "long", - "poll_default_backend": "long", - } - self.basic_test(data) - self.assert_model_exists("meeting/1", data) - def test_update_poll_default_live_voting_enabled(self) -> None: self.basic_test({"poll_default_live_voting_enabled": True}) self.assert_model_exists( @@ -304,15 +295,18 @@ def test_update_poll_default_live_voting_enabled(self) -> None: {"poll_default_live_voting_enabled": True}, ) + def test_update_poll_default_allow_invalid(self) -> None: + self.basic_test({"poll_default_allow_invalid": True}) + self.assert_model_exists("meeting/1", {"poll_default_allow_invalid": True}) + + def test_update_poll_default_allow_vote_split(self) -> None: + self.basic_test({"poll_default_allow_vote_split": True}) + self.assert_model_exists("meeting/1", {"poll_default_allow_vote_split": True}) + def test_update_motions_block_slide_columns(self) -> None: self.basic_test({"motions_block_slide_columns": 2}) self.assert_model_exists("meeting/1", {"motions_block_slide_columns": 2}) - def test_update_topic_poll_default_group(self) -> None: - self.basic_test({"topic_poll_default_group_ids": [3]}) - self.assert_model_exists("meeting/1", {"topic_poll_default_group_ids": [3]}) - self.assert_model_exists("group/3", {"used_as_topic_poll_default_id": 1}) - def test_update_only_one_time_1(self) -> None: response_message = self.basic_test({"start_time": 150000}, check_200=False) self.assertEqual( diff --git a/openslides_backend/services/vote/__init__.py b/tests/system/action/meeting_poll_default/__init__.py similarity index 100% rename from openslides_backend/services/vote/__init__.py rename to tests/system/action/meeting_poll_default/__init__.py diff --git a/tests/system/action/meeting_poll_default/test_create.py b/tests/system/action/meeting_poll_default/test_create.py new file mode 100644 index 0000000000..09c3b93d4e --- /dev/null +++ b/tests/system/action/meeting_poll_default/test_create.py @@ -0,0 +1,206 @@ +from openslides_backend.models.models import Poll +from tests.system.action.base import BaseActionTestCase + + +class MeetingPollDefaultCreateActionTest(BaseActionTestCase): + def setUp(self) -> None: + super().setUp() + self.create_meeting(17) + + def test_create_simple_config_assignment(self) -> None: + response = self.request( + "meeting_poll_default.create", + {"meeting_id": 17, "used_as_assignment_poll_config_in_meeting_id": 17}, + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting_poll_default/1", + { + "meeting_id": 17, + "used_as_assignment_poll_config_in_meeting_id": 17, + "sort_result_by_votes": True, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "visibility": Poll.VISIBILITY_SECRET, + "group_ids": None, + "display_chart": None, + }, + ) + self.assert_model_exists( + "meeting/17", {"poll_default_ids": [1], "assignment_poll_config_id": 1} + ) + + def test_create_simple_config_motion(self) -> None: + response = self.request( + "meeting_poll_default.create", + {"meeting_id": 17, "used_as_motion_poll_config_in_meeting_id": 17}, + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting_poll_default/1", + { + "meeting_id": 17, + "used_as_motion_poll_config_in_meeting_id": 17, + "sort_result_by_votes": True, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "visibility": Poll.VISIBILITY_SECRET, + "group_ids": None, + "display_chart": None, + }, + ) + self.assert_model_exists( + "meeting/17", {"poll_default_ids": [1], "motion_poll_config_id": 1} + ) + + def test_create_simple_config_topic(self) -> None: + response = self.request( + "meeting_poll_default.create", + {"meeting_id": 17, "used_as_topic_poll_config_in_meeting_id": 17}, + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "meeting_poll_default/1", + { + "meeting_id": 17, + "used_as_topic_poll_config_in_meeting_id": 17, + "sort_result_by_votes": True, + "allow_abstain": True, + "allow_nota": False, + "strike_out": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "visibility": Poll.VISIBILITY_MANUALLY, + "group_ids": None, + "display_chart": "pie", + }, + ) + self.assert_model_exists( + "meeting/17", {"poll_default_ids": [1], "topic_poll_config_id": 1} + ) + + def test_create_set_all_fields(self) -> None: + data = { + "meeting_id": 17, + "used_as_topic_poll_config_in_meeting_id": 17, + "sort_result_by_votes": False, + "visibility": "named", + "allow_abstain": False, + "allow_nota": True, + "strike_out": True, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_CAST, + "group_ids": [19], + "display_chart": "pie", + } + response = self.request("meeting_poll_default.create", data) + self.assert_status_code(response, 200) + self.assert_model_exists("meeting_poll_default/1", data) + self.assert_model_exists( + "meeting/17", {"poll_default_ids": [1], "topic_poll_config_id": 1} + ) + self.assert_model_exists( + "group/19", + {"used_in_meeting_poll_default_ids": [1]}, + ) + + def test_create_wrong_field(self) -> None: + response = self.request( + "meeting_poll_default.create", + { + "meeting_id": 17, + "used_as_motion_poll_config_in_meeting_id": 17, + "wrong_field": "test", + }, + ) + + self.assert_status_code(response, 400) + self.assertIn( + "data must not contain {'wrong_field'} properties", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") + + def test_create_empty_data(self) -> None: + response = self.request("meeting_poll_default.create", {}) + self.assert_status_code(response, 400) + self.assertIn( + "data must contain ['meeting_id'] properties", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") + + def test_create_no_poll_type_fields(self) -> None: + response = self.request("meeting_poll_default.create", {"meeting_id": 17}) + self.assert_status_code(response, 400) + self.assertEqual( + "One of the fields ['used_as_assignment_poll_config_in_meeting_id', 'used_as_motion_poll_config_in_meeting_id', 'used_as_topic_poll_config_in_meeting_id'] must be set.", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") + + def test_create_multiple_poll_type_fields(self) -> None: + response = self.request( + "meeting_poll_default.create", + { + "meeting_id": 17, + "used_as_assignment_poll_config_in_meeting_id": 17, + "used_as_topic_poll_config_in_meeting_id": 17, + }, + ) + self.assert_status_code(response, 400) + self.assertEqual( + "Only one of ['used_as_assignment_poll_config_in_meeting_id', 'used_as_motion_poll_config_in_meeting_id', 'used_as_topic_poll_config_in_meeting_id'] can be set.", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") + + def test_create_meeting_id_mismatch(self) -> None: + self.create_meeting() + response = self.request( + "meeting_poll_default.create", + {"meeting_id": 17, "used_as_assignment_poll_config_in_meeting_id": 1}, + ) + self.assert_status_code(response, 400) + self.assertEqual( + "Values in fields 'used_as_assignment_poll_config_in_meeting_id' and 'meeting_id' don't match.", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") + + def test_create_duplicate(self) -> None: + self.set_models( + { + "meeting_poll_default/21": {"meeting_id": 17}, + "meeting/17": {"assignment_poll_config_id": 21}, + } + ) + response = self.request( + "meeting_poll_default.create", + {"meeting_id": 17, "used_as_assignment_poll_config_in_meeting_id": 17}, + ) + self.assert_status_code(response, 400) + self.assert_model_not_exists("meeting_poll_default/2") + self.assertEqual( + "'used_as_assignment_poll_config_in_meeting_id' already exists in meeting/17.", + response.json["message"], + ) + + def test_create_group_ids_not_in_meeting(self) -> None: + self.create_meeting() + response = self.request( + "meeting_poll_default.create", + { + "meeting_id": 17, + "used_as_assignment_poll_config_in_meeting_id": 17, + "group_ids": [1], + }, + ) + self.assert_status_code(response, 400) + self.assertEqual( + "The following models do not belong to meeting 17: ['group/1']", + response.json["message"], + ) + self.assert_model_not_exists("meeting_poll_default/1") diff --git a/tests/system/action/meeting_poll_default/test_update.py b/tests/system/action/meeting_poll_default/test_update.py new file mode 100644 index 0000000000..cb7085c531 --- /dev/null +++ b/tests/system/action/meeting_poll_default/test_update.py @@ -0,0 +1,77 @@ +from tests.system.action.base import BaseActionTestCase + + +class MeetingPollDefaultUpdateActionTest(BaseActionTestCase): + def setUp(self) -> None: + super().setUp() + self.create_meeting(17) + self.set_models( + { + "meeting_poll_default/21": {"meeting_id": 17}, + "meeting/17": {"topic_poll_config_id": 21}, + } + ) + + def test_update_correctly(self) -> None: + response = self.request( + "meeting_poll_default.update", + {"id": 21, "visibility": "named"}, + ) + self.assert_status_code(response, 200) + self.assert_model_exists("meeting_poll_default/21", {"visibility": "named"}) + + def test_update_erase_data(self) -> None: + data = { + "sort_result_by_votes": None, + "visibility": None, + "allow_abstain": None, + "allow_nota": None, + "strike_out": None, + "onehundred_percent_base": None, + "group_ids": None, + "display_chart": None, + } + response = self.request("meeting_poll_default.update", {"id": 21, **data}) + self.assert_status_code(response, 200) + self.assert_model_exists("meeting_poll_default/21", data) + + def test_update_wrong_id(self) -> None: + response = self.request( + "meeting_poll_default.update", {"id": 200, "allow_abstain": False} + ) + self.assert_status_code(response, 400) + self.assertIn( + "Model 'meeting_poll_default/200' does not exist.", + response.json["message"], + ) + + def test_update_wrong_field(self) -> None: + response = self.request( + "meeting_poll_default.update", + { + "id": 21, + "allow_nota": True, + "wrong_id": "eleven", + }, + ) + self.assert_status_code(response, 400) + self.assert_model_exists("meeting_poll_default/21", {"allow_nota": False}) + self.assertIn( + "data must not contain {'wrong_id'} properties", response.json["message"] + ) + + def test_update_group_ids_not_in_meeting(self) -> None: + self.create_meeting() + response = self.request( + "meeting_poll_default.update", + { + "id": 21, + "group_ids": [1], + }, + ) + self.assert_status_code(response, 400) + self.assertEqual( + "The following models do not belong to meeting 1: ['meeting_poll_default/21']", + response.json["message"], + ) + self.assert_model_exists("meeting_poll_default/21", {"group_ids": None}) diff --git a/tests/system/action/meeting_user/test_update.py b/tests/system/action/meeting_user/test_update.py index 665910336d..d292e9622d 100644 --- a/tests/system/action/meeting_user/test_update.py +++ b/tests/system/action/meeting_user/test_update.py @@ -1,6 +1,8 @@ from datetime import datetime from decimal import Decimal +from typing import Any +from openslides_backend.models.models import Poll from tests.system.action.base import BaseActionTestCase @@ -106,6 +108,101 @@ def test_update_merge_fields_correct(self) -> None: }, ) + def test_update_with_poll_related_fields(self) -> None: + self.create_meeting() + self.set_user_groups(1, [1]) + self.create_user("dummy2", [1]) + self.create_user("dummy3", [1]) + self.set_models( + { + "meeting_user/1": { + "acting_ballot_ids": [1, 2], + "represented_ballot_ids": [1], + }, + "poll/1": { + "title": "Poll 1", + "meeting_id": 1, + "content_object_id": "assignment/1", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/1", + "state": Poll.STATE_FINISHED, + }, + "list_of_speakers/1": { + "id": 1, + "closed": False, + "meeting_id": 1, + "content_object_id": "assignment/1", + "sequential_number": 1, + }, + "assignment/1": { + "id": 1, + "phase": "search", + "title": "Duckburg town council", + "meeting_id": 1, + "sequential_number": 1, + "list_of_speakers_id": 1, + }, + "poll_option/1": { + "poll_id": 1, + "meeting_user_id": 2, + }, + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_ballot/1": { + "poll_id": 1, + "poll_ballot_user_id": 1, + }, + "poll_ballot_user/1": { + "poll_id": 1, + "acting_meeting_user_id": 1, + "represented_meeting_user_id": 1, + }, + "poll_ballot/2": { + "poll_id": 1, + "poll_ballot_user_id": 2, + }, + "poll_ballot_user/2": { + "poll_id": 1, + "acting_meeting_user_id": 1, + "represented_meeting_user_id": 2, + }, + } + ) + response = self.request( + "meeting_user.update", + { + "id": 3, + "poll_option_ids": [1], + "acting_ballot_ids": [1], + "represented_ballot_ids": [2], + }, + internal=True, + ) + self.assert_status_code(response, 200) + expected: dict[str, dict[str, Any]] = { + "meeting_user/3": { + "poll_option_ids": [1], + "acting_ballot_ids": [1], + "represented_ballot_ids": [2], + }, + "poll/1": {"ballot_user_ids": [1, 2]}, + "poll_option/1": {"meeting_user_id": 3}, + "poll_ballot_user/1": { + "poll_id": 1, + "acting_meeting_user_id": 3, + "represented_meeting_user_id": 1, + }, + "poll_ballot_user/2": { + "poll_id": 1, + "poll_ballot_id": 2, + "acting_meeting_user_id": 1, + "represented_meeting_user_id": 3, + }, + } + for fqid, model in expected.items(): + self.assert_model_exists(fqid, model) + def test_update_anonymous_group_id(self) -> None: self.create_meeting() self.set_models( diff --git a/tests/system/action/option/__init__.py b/tests/system/action/option/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/system/action/option/test_create.py b/tests/system/action/option/test_create.py deleted file mode 100644 index f7e8b47290..0000000000 --- a/tests/system/action/option/test_create.py +++ /dev/null @@ -1,154 +0,0 @@ -from decimal import Decimal - -from tests.system.action.base import BaseActionTestCase - - -class OptionCreateActionTest(BaseActionTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting(111) - - def test_create(self) -> None: - response = self.request( - "option.create", {"text": "testtesttest", "meeting_id": 111, "weight": 10} - ) - self.assert_status_code(response, 200) - model = self.get_model("option/1") - assert model.get("text") == "testtesttest" - assert model.get("meeting_id") == 111 - assert model.get("weight") == 10 - - def test_create_without_text_and_content_object_id(self) -> None: - response = self.request("option.create", {"meeting_id": 111, "weight": 10}) - self.assert_status_code(response, 400) - assert ( - "Need one of text, content_object_id or poll_candidate_user_ids." - in response.json["message"] - ) - - def test_create_with_both_text_and_content_object_id(self) -> None: - self.set_models( - { - "motion/112": { - "title": "mosh pit", - "state_id": 111, - "meeting_id": 111, - }, - "list_of_speakers/23": { - "content_object_id": "motion/112", - "meeting_id": 111, - }, - } - ) - response = self.request( - "option.create", - { - "text": "test", - "content_object_id": "motion/112", - "meeting_id": 111, - "weight": 10, - }, - ) - self.assert_status_code(response, 400) - assert ( - "Need one of text, content_object_id or poll_candidate_user_ids." - in response.json["message"] - ) - - def test_create_text_not_unique(self) -> None: - self.create_motion(111, 112) - self.set_models( - { - "poll/65": { - "title": "pool", - "content_object_id": "motion/112", - "type": "analog", - "state": "created", - "pollmethod": "Y", - "meeting_id": 111, - }, - "option/78": { - "text": "test", - "meeting_id": 111, - "weight": 9, - "poll_id": 65, - }, - } - ) - response = self.request( - "option.create", - {"text": "test", "meeting_id": 111, "weight": 10, "poll_id": 65}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/79", - {"text": "test", "meeting_id": 111, "weight": 10, "poll_id": 65}, - ) - - def test_create_content_object_id_not_unique(self) -> None: - self.set_models( - { - "motion/112": { - "title": "mosh pit", - "state_id": 111, - "meeting_id": 111, - }, - "list_of_speakers/23": { - "content_object_id": "motion/112", - "meeting_id": 111, - }, - "poll/65": { - "title": "pool", - "content_object_id": "motion/112", - "type": "analog", - "state": "created", - "pollmethod": "Y", - "meeting_id": 111, - }, - "option/78": { - "content_object_id": "motion/112", - "meeting_id": 111, - "weight": 9, - "poll_id": 65, - }, - } - ) - response = self.request( - "option.create", - { - "content_object_id": "motion/112", - "meeting_id": 111, - "weight": 10, - "poll_id": 65, - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - 'option/79: duplicate key value violates unique constraint "unique_option_content_object_id_poll_id"\n' - + "DETAIL: Key (content_object_id, poll_id)=(motion/112, 65) already exists.", - response.json["message"], - ) - - def test_create_yna_votes(self) -> None: - response = self.request( - "option.create", - { - "text": "test", - "meeting_id": 111, - "weight": 10, - "yes": "1.000000", - "no": "2.500000", - "abstain": "0.666667", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("option/1", {"vote_ids": [1, 2, 3], "text": "test"}) - self.assert_model_exists( - "vote/1", {"value": "Y", "weight": Decimal("1.000000"), "option_id": 1} - ) - self.assert_model_exists( - "vote/2", {"value": "N", "weight": Decimal("2.500000"), "option_id": 1} - ) - self.assert_model_exists( - "vote/3", {"value": "A", "weight": Decimal("0.666667"), "option_id": 1} - ) diff --git a/tests/system/action/option/test_delete.py b/tests/system/action/option/test_delete.py deleted file mode 100644 index 2e4b475894..0000000000 --- a/tests/system/action/option/test_delete.py +++ /dev/null @@ -1,48 +0,0 @@ -from decimal import Decimal - -from tests.system.action.base import BaseActionTestCase - - -class OptionDeleteTest(BaseActionTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.create_motion(1) - self.set_models( - { - "option/111": {"meeting_id": 1, "content_object_id": "motion/1"}, - } - ) - - def test_delete_correct(self) -> None: - response = self.request("option.delete", {"id": 111}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("option/111") - self.assert_model_exists("motion/1") - - def test_delete_wrong_id(self) -> None: - response = self.request("option.delete", {"id": 112}) - self.assert_status_code(response, 400) - self.assert_model_exists("option/111") - - def test_delete_correct_cascading(self) -> None: - self.set_models( - { - "option/112": { - "meeting_id": 1, - "content_object_id": "poll_candidate_list/2", - }, - "vote/42": { - "user_token": "imnotapropertoken", - "option_id": 112, - "meeting_id": 1, - "weight": Decimal("1.000000"), - "value": "Y", - }, - "poll_candidate_list/2": {"option_id": 112, "meeting_id": 1}, - } - ) - response = self.request("option.delete", {"id": 112}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("option/112") - self.assert_model_not_exists("vote/42") diff --git a/tests/system/action/option/test_update.py b/tests/system/action/option/test_update.py deleted file mode 100644 index 11a46c3df3..0000000000 --- a/tests/system/action/option/test_update.py +++ /dev/null @@ -1,278 +0,0 @@ -from decimal import Decimal -from typing import Any - -from openslides_backend.permissions.permissions import Permissions -from tests.system.action.base import BaseActionTestCase - - -class OptionUpdateActionTest(BaseActionTestCase): - def setUp(self) -> None: - super().setUp() - self.vote_models: dict[str, dict[str, Any]] = { - "option/57": { - "vote_ids": [22], - }, - "vote/22": { - "value": "Y", - "user_token": "imnotapropertoken", - "weight": "0.000000", - "meeting_id": 1, - "option_id": 57, - }, - } - self.create_meeting() - self.set_models( - { - "topic/1": { - "title": "to pic", - "meeting_id": 1, - }, - "agenda_item/1": {"content_object_id": "topic/1", "meeting_id": 1}, - "list_of_speakers/23": { - "content_object_id": "topic/1", - "meeting_id": 1, - }, - "poll/65": { - "title": "pool", - "content_object_id": "topic/1", - "type": "analog", - "state": "created", - "pollmethod": "YNA", - "max_votes_amount": 1, - "max_votes_per_option": 1, - "min_votes_amount": 1, - "meeting_id": 1, - "option_ids": [57], - }, - "option/57": { - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "meeting_id": 1, - "poll_id": 65, - }, - } - ) - - def test_update(self) -> None: - self.set_models(self.vote_models) - response = self.request( - "option.update", - {"id": 57, "Y": "1.000000", "N": "2.000000", "A": "3.000000"}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/57", - { - "yes": Decimal("1.000000"), - "no": Decimal("2.000000"), - "abstain": Decimal("3.000000"), - "vote_ids": [22, 23, 24], - }, - ) - self.assert_model_exists( - "vote/22", {"option_id": 57, "value": "Y", "weight": Decimal("1.000000")} - ) - self.assert_model_exists( - "vote/23", {"option_id": 57, "value": "N", "weight": Decimal("2.000000")} - ) - self.assert_model_exists( - "vote/24", {"option_id": 57, "value": "A", "weight": Decimal("3.000000")} - ) - poll = self.get_model("poll/65") - assert poll.get("state") == "finished" - - def test_update_Y(self) -> None: - response = self.request( - "option.update", - { - "id": 57, - "Y": "1.000000", - "N": "2.000000", - "A": "3.000000", - "publish_immediately": True, - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/57", - { - "yes": Decimal("1.000000"), - "no": Decimal("2.000000"), - "abstain": Decimal("3.000000"), - "publish_immediately": None, - }, - ) - poll = self.get_model("poll/65") - assert poll.get("state") == "published" - - def test_update_default_values(self) -> None: - self.set_models( - { - "poll/65": { - "pollmethod": "YN", - }, - "option/57": { - "abstain": None, - }, - } - ) - response = self.request( - "option.update", - { - "id": 57, - "Y": "1.000000", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/57", - {"yes": Decimal("1.000000"), "no": Decimal("-2.000000"), "abstain": None}, - ) - - def test_update_invalid_keys(self) -> None: - self.set_models( - { - "poll/65": { - "pollmethod": "YN", - }, - } - ) - response = self.request( - "option.update", - { - "id": 57, - "A": "0.000000", - }, - ) - self.assert_status_code(response, 400) - assert ( - "Pollmethod YN does not support abstain votes" in response.json["message"] - ) - - def test_update_global_option(self) -> None: - self.set_models( - { - "poll/65": { - "pollmethod": "Y", - "global_option_id": 57, - "global_yes": True, - "global_no": True, - "global_abstain": True, - }, - "option/57": {"used_as_global_option_in_poll_id": 65}, - } - ) - response = self.request( - "option.update", - {"id": 57, "Y": "1.000000", "N": "2.000000"}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/57", - { - "yes": Decimal("1.000000"), - "no": Decimal("2.000000"), - "abstain": Decimal("-2.000000"), - }, - ) - - def test_update_global_option_invalid(self) -> None: - self.set_models({"poll/65": {"global_option_id": 57}}) - response = self.request( - "option.update", - {"id": 57, "Y": "1.000000"}, - ) - self.assert_status_code(response, 400) - assert ( - "Global yes votes are not allowed for this poll" in response.json["message"] - ) - - def test_update_no_permissions(self) -> None: - self.base_permission_test( - self.vote_models, - "option.update", - {"id": 57, "Y": "1.000000", "N": "2.000000", "A": "3.000000"}, - ) - - def test_update_permissions(self) -> None: - self.base_permission_test( - self.vote_models, - "option.update", - {"id": 57, "Y": "1.000000", "N": "2.000000", "A": "3.000000"}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_update_permissions_locked_meeting(self) -> None: - self.base_locked_out_superadmin_permission_test( - self.vote_models, - "option.update", - {"id": 57, "Y": "1.000000", "N": "2.000000", "A": "3.000000"}, - ) - - def test_update_together_with_poll(self) -> None: - self.set_models( - { - "poll/65": { - "pollmethod": "YN", - "option_ids": [57, 58], - }, - "option/58": { - "yes": "0.000000", - "no": "0.000000", - "abstain": "0.000000", - "meeting_id": 1, - "poll_id": 65, - }, - } - ) - response = self.request_json( - [ - { - "action": "poll.update", - "data": [ - { - "id": 65, - "onehundred_percent_base": "valid", - "pollmethod": "YNA", - "title": "Ballot", - } - ], - }, - { - "action": "option.update", - "data": [ - {"id": 57, "Y": "1.000000", "A": "3.000000", "N": "2.000000"}, - {"id": 58, "Y": "4.000000", "N": "-1.000000"}, - ], - }, - ] - ) - self.assert_status_code(response, 200) - - def test_update_together_with_poll_2(self) -> None: - response = self.request_json( - [ - { - "action": "poll.update", - "data": [ - { - "id": 65, - "onehundred_percent_base": "YNA", - "pollmethod": "YNA", - "title": "Abstimmung", - "votescast": "10.000000", - "votesinvalid": "10.000000", - "votesvalid": "10.000000", - } - ], - }, - { - "action": "option.update", - "data": [ - {"id": 57, "Y": "10.000000", "A": "10.000000", "N": "10.000000"} - ], - }, - ] - ) - self.assert_status_code(response, 200) diff --git a/tests/system/action/organization/test_delete_history_information.py b/tests/system/action/organization/test_delete_history_information.py index c5468519c7..6fab0196cd 100644 --- a/tests/system/action/organization/test_delete_history_information.py +++ b/tests/system/action/organization/test_delete_history_information.py @@ -1,3 +1,7 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from openslides_backend.models.models import Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from tests.system.action.base import BaseActionTestCase @@ -12,6 +16,7 @@ def test_delete_history_information_no_permission(self) -> None: def test_delete_history_information_correct(self) -> None: self.create_meeting() + self.create_user_for_meeting(1) self.set_models( { "organization/1": {"name": "Orga", "enable_electronic_voting": True}, @@ -24,24 +29,35 @@ def test_delete_history_information_correct(self) -> None: "content_object_id": "assignment/1", "meeting_id": 1, }, + "poll/1": { + "title": "test", + "meeting_id": 1, + "content_object_id": "assignment/1", + "visibility": Poll.VISIBILITY_MANUALLY, + "state": Poll.STATE_FINISHED, + "config_id": "poll_config_rating_approval/1", + "result": '{"yes": "3", "no": "2", "abstain": "1"}', + }, + "poll_config_rating_approval/1": { + "poll_id": 1, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + "allow_abstain": True, + }, + "poll_option/1": {"poll_id": 1, "text": "Delete this item?"}, + "history_position/1": { + "timestamp": datetime.fromtimestamp(1761760881, ZoneInfo("UTC")), + "original_user_id": 1, + "user_id": 1, + }, + "history_entry/1": { + "position_id": 1, + "meeting_id": 1, + "entries": ["Ballot created"], + "original_model_id": "assignment/1", + "model_id": "assignment/1", + }, } ) - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 200) - self.assert_history_information("assignment/1", ["Ballot created"]) - for collection in ["history_position", "history_entry"]: - self.assert_model_exists(f"{collection}/1") - self.assert_model_not_exists(f"{collection}/2") response = self.request("organization.delete_history_information", {"id": 1}) self.assert_status_code(response, 200) diff --git a/tests/system/action/poll/__init__.py b/tests/system/action/poll/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/system/action/poll/base_poll_test.py b/tests/system/action/poll/base_poll_test.py deleted file mode 100644 index 3ff8a8cec8..0000000000 --- a/tests/system/action/poll/base_poll_test.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Any - -from tests.system.action.base import BaseActionTestCase - - -class BasePollTestCase(BaseActionTestCase): - def setUp(self) -> None: - super().setUp() - self.vote_service.clear_all() - - def create_assignment( - self, base: int, meeting_id: int, assignment_data: dict[str, Any] = {} - ) -> None: - self.set_models( - { - f"assignment/{base}": { - "title": "just do it", - "meeting_id": meeting_id, - **assignment_data, - }, - f"list_of_speakers/{base + 100}": { - "content_object_id": f"assignment/{base}", - "meeting_id": meeting_id, - }, - } - ) diff --git a/tests/system/action/poll/poll_test_mixin.py b/tests/system/action/poll/poll_test_mixin.py deleted file mode 100644 index b13691987b..0000000000 --- a/tests/system/action/poll/poll_test_mixin.py +++ /dev/null @@ -1,45 +0,0 @@ -from openslides_backend.models.models import Poll -from tests.system.action.base import BaseActionTestCase -from tests.system.base import ADMIN_PASSWORD, ADMIN_USERNAME, DEFAULT_PASSWORD - - -class PollTestMixin(BaseActionTestCase): - def start_poll(self, id: int) -> None: - self.vote_service.start(id) - - def prepare_users_and_poll(self, user_count: int) -> list[int]: - user_ids = list(range(2, user_count + 2)) - self.create_meeting() - self.create_motion(1, 1) - self.set_models( - { - "poll/1": { - "content_object_id": "motion/1", - "type": Poll.TYPE_NAMED, - "pollmethod": "YNA", - "backend": "fast", - "state": Poll.STATE_STARTED, - "meeting_id": 1, - "onehundred_percent_base": "YNA", - "title": "Poll 1", - }, - "option/1": {"meeting_id": 1, "poll_id": 1}, - **{f"user/{i}": {**self._get_user_data(f"user{i}")} for i in user_ids}, - **{ - f"meeting_user/{i+10}": {"meeting_id": 1, "user_id": i} - for i in user_ids - }, - "group/3": { - "meeting_user_ids": [id_ + 10 for id_ in user_ids], - "poll_ids": [1], - }, - "meeting/1": {"present_user_ids": user_ids}, - } - ) - self.start_poll(1) - for i in user_ids: - self.client.login(f"user{i}", DEFAULT_PASSWORD) - response = self.vote_service.vote({"id": 1, "value": {"1": "Y"}}) - self.assert_status_code(response, 200) - self.client.login(ADMIN_USERNAME, ADMIN_PASSWORD) - return user_ids diff --git a/tests/system/action/poll/test_anonymize.py b/tests/system/action/poll/test_anonymize.py deleted file mode 100644 index 8e20233939..0000000000 --- a/tests/system/action/poll/test_anonymize.py +++ /dev/null @@ -1,144 +0,0 @@ -from decimal import Decimal -from typing import Any - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions -from openslides_backend.services.database.interface import PartialModel - -from .base_poll_test import BasePollTestCase - - -class PollAnonymize(BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.create_topic(1, 1) - self.set_user_groups(1, [1]) - self.test_data: dict[str, dict[str, Any]] = { - "poll/1": { - "title": "Poll 1", - "global_option_id": 2, - "meeting_id": 1, - "state": Poll.STATE_FINISHED, - "type": Poll.TYPE_NAMED, - "content_object_id": "topic/1", - "pollmethod": "Y", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_Y, - }, - "option/1": {"meeting_id": 1, "poll_id": 1}, - "option/2": {"meeting_id": 1, "poll_id": 1}, - "vote/1": { - "user_id": 1, - "meeting_id": 1, - "delegated_user_id": 1, - "user_token": "abc", - "option_id": 1, - "weight": Decimal("1.000000"), - "value": "Y", - }, - "vote/2": { - "user_id": 1, - "meeting_id": 1, - "delegated_user_id": 1, - "user_token": "edf", - "option_id": 2, - "weight": Decimal("2.000000"), - "value": "Y", - }, - } - - def set_test_data(self, poll_data: PartialModel = {}) -> None: - if poll_data: - self.test_data["poll/1"].update(poll_data) - self.set_models(self.test_data) - - def assert_anonymize(self) -> None: - self.assert_model_exists("poll/1", {"is_pseudoanonymized": True}) - for fqid in ("vote/1", "vote/2"): - self.assert_model_exists(fqid, {"user_id": None, "delegated_user_id": None}) - self.assert_model_exists( - "user/1", {"vote_ids": None, "delegated_vote_ids": None} - ) - - def test_anonymize(self) -> None: - self.set_test_data() - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_anonymize() - self.assert_history_information("topic/1", None) - - def test_anonymize_motion_poll(self) -> None: - self.create_motion(1, 11) - self.set_models( - { - "poll/1": { - "title": "Poll 1", - "meeting_id": 1, - "type": Poll.TYPE_NAMED, - "state": Poll.STATE_FINISHED, - "content_object_id": "motion/11", - "pollmethod": "Y", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_Y, - }, - } - ) - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("motion/11", ["Voting anonymized"]) - - def test_anonymize_assignment_poll(self) -> None: - self.create_assignment(1, 1) - self.set_test_data({"content_object_id": "assignment/1"}) - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("assignment/1", ["Ballot anonymized"]) - - def test_anonymize_publish_state(self) -> None: - self.set_test_data({"state": Poll.STATE_PUBLISHED}) - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_anonymize() - - def test_anonymize_wrong_state(self) -> None: - self.set_test_data({"state": Poll.STATE_CREATED}) - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 400) - for vote_fqid in ("vote/1", "vote/2"): - self.assert_model_exists(vote_fqid, {"user_id": 1, "delegated_user_id": 1}) - self.assertEqual( - "Anonymizing can only be done after finishing a poll.", - response.json["message"], - ) - - def test_anonymize_wrong_type(self) -> None: - self.set_test_data({"type": Poll.TYPE_ANALOG}) - response = self.request("poll.anonymize", {"id": 1}) - self.assert_status_code(response, 400) - for vote_fqid in ("vote/1", "vote/2"): - self.assert_model_exists(vote_fqid, {"user_id": 1, "delegated_user_id": 1}) - self.assertEqual( - "You can only anonymize named polls.", - response.json["message"], - ) - - def test_anonymize_no_permissions(self) -> None: - self.base_permission_test( - self.test_data, - "poll.anonymize", - {"id": 1}, - ) - - def test_anonymize_permissions(self) -> None: - self.base_permission_test( - self.test_data, - "poll.anonymize", - {"id": 1}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_anonymize_permissions_locked_meeting(self) -> None: - self.base_locked_out_superadmin_permission_test( - self.test_data, - "poll.anonymize", - {"id": 1}, - ) diff --git a/tests/system/action/poll/test_create.py b/tests/system/action/poll/test_create.py deleted file mode 100644 index 4e2333e59c..0000000000 --- a/tests/system/action/poll/test_create.py +++ /dev/null @@ -1,1243 +0,0 @@ -import typing -from decimal import Decimal - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions -from openslides_backend.shared.util import ONE_ORGANIZATION_FQID - -from .base_poll_test import BasePollTestCase - - -class CreatePoll(BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.create_assignment(1, 1, {"open_posts": 1}) - - def test_create_correct(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "min_votes_amount": 5, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "backend": "long", - "amount_global_yes": "2.000000", - }, - ) - self.assert_status_code(response, 200) - assert response.json["results"][0][0] == {"id": 1, "sequential_number": 1} - poll = self.assert_model_exists( - "poll/1", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "meeting_id": 1, - "option_ids": [1], - "global_option_id": 2, - "state": "finished", - "onehundred_percent_base": "Y", - "is_pseudoanonymized": False, - "min_votes_amount": 5, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "backend": "long", - "sequential_number": 1, - }, - ) - assert "options" not in poll - self.assert_model_exists( - "option/1", - { - "text": "test2", - "poll_id": 1, - "meeting_id": 1, - "yes": Decimal("10.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "text": "global option", - "used_as_global_option_in_poll_id": 1, - "meeting_id": 1, - "yes": Decimal("2.000000"), - "no": Decimal("-2.000000"), - }, - ) - self.assert_history_information("assignment/1", ["Ballot created"]) - - def test_create_correct_publish_immediately(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "min_votes_amount": 5, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "publish_immediately": True, - }, - ) - self.assert_status_code(response, 200) - poll = self.assert_model_exists("poll/1", {"state": "published"}) - assert "publish_immediately" not in poll - - def test_create_correct_default_values(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "YN", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "onehundred_percent_base": "Y", - "votesvalid": "3.000000", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - {"votesvalid": Decimal("3.000000"), "votesinvalid": Decimal("-2.000000")}, - ) - self.assert_model_exists( - "vote/1", {"value": "Y", "weight": Decimal("10.000000")} - ) - self.assert_model_exists( - "vote/2", {"value": "N", "weight": Decimal("-2.000000")} - ) - - def test_create_correct_with_topic(self) -> None: - self.create_topic(12, 1, {"title": "Wichtiges Topic", "text": "blablabla"}) - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "topic/12", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "min_votes_amount": 5, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "publish_immediately": True, - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"content_object_id": "topic/12"}) - self.assert_model_exists( - "topic/12", - { - "title": "Wichtiges Topic", - "text": "blablabla", - "meeting_id": 1, - "poll_ids": [1], - }, - ) - - def test_create_three_options(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "YNA", - "options": [ - {"text": "test2", "Y": "10.000000"}, - {"text": "test3", "N": "0.999900"}, - {"text": "test4", "N": "11.000000"}, - ], - "meeting_id": 1, - "onehundred_percent_base": "YNA", - "content_object_id": "assignment/1", - "amount_global_yes": "5.000000", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "title": "test", - "type": "analog", - "pollmethod": "YNA", - "meeting_id": 1, - "option_ids": [1, 2, 3], - "global_option_id": 4, - "onehundred_percent_base": "YNA", - }, - ) - self.assert_model_exists( - "option/1", - { - "text": "test2", - "poll_id": 1, - "meeting_id": 1, - "yes": Decimal("10.000000"), - "weight": 1, - }, - ) - self.assert_model_exists( - "option/2", - { - "text": "test3", - "poll_id": 1, - "meeting_id": 1, - "no": Decimal("0.999900"), - "weight": 2, - }, - ) - self.assert_model_exists( - "option/3", - { - "text": "test4", - "poll_id": 1, - "meeting_id": 1, - "no": Decimal("11.000000"), - "weight": 3, - }, - ) - self.assert_model_exists( - "option/4", - { - "text": "global option", - "used_as_global_option_in_poll_id": 1, - "meeting_id": 1, - "weight": 1, - "yes": None, - }, - ) - - def test_all_fields(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_ahThai4pae1pi4xoogoo", - "pollmethod": "YN", - "type": "pseudoanonymous", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "global_yes": False, - "global_no": False, - "global_abstain": False, - "description": "test_description_ieM8ThuasoSh8aecai8p", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "title": "test_title_ahThai4pae1pi4xoogoo", - "description": "test_description_ieM8ThuasoSh8aecai8p", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "pollmethod": "YN", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_YN, - "is_pseudoanonymized": True, - "option_ids": [1], - "global_yes": False, - "global_no": False, - "global_abstain": False, - }, - ) - self.assert_model_exists( - "option/1", - { - "text": "test", - "poll_id": 1, - "meeting_id": 1, - "weight": 1, - }, - ) - - def test_create_wrong_publish_immediately(self) -> None: - response = self.request( - "poll.create", - { - "content_object_id": "assignment/1", - "title": "test_title_ahThai4pae1pi4xoogoo", - "pollmethod": "YN", - "type": "pseudoanonymous", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_YN, - "meeting_id": 1, - "options": [{"text": "test"}], - "publish_immediately": True, - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "publish_immediately only allowed for analog polls.", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_no_options(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "Action poll.create: data.options must contain at least 1 items", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_invalid_options(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{}], - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - "Need one of text, content_object_id or poll_candidate_user_ids.", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_missing_keys(self) -> None: - complete_request_data = { - "title": "test_title_keugh8Iu9ciyooGaevoh", - "pollmethod": "YNA", - "type": "named", - "meeting_id": 1, - "options": [{"text": "test"}], - } - for key in complete_request_data.keys(): - request_data = { - _key: value - for _key, value in complete_request_data.items() - if _key != key - } - response = self.request("poll.create", request_data) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - - def test_with_groups(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "entitled_group_ids": [1, 2], - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"entitled_group_ids": [1, 2]}) - - def test_with_empty_groups(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "entitled_group_ids": [], - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"entitled_group_ids": None}) - - def test_with_groups_and_analog(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "analog", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "entitled_group_ids": [1, 2], - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "entitled_group_ids is not allowed for analog.", response.json["message"] - ) - - def test_with_100_percent_base_entitled_and_analog(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "analog", - "content_object_id": "assignment/1", - "onehundred_percent_base": "entitled", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "onehundred_percent_base: value entitled is not allowed for analog.", - response.json["message"], - ) - - def test_not_supported_type(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", - "type": "not_existing", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - - def test_not_allowed_type(self) -> None: - self.update_model(ONE_ORGANIZATION_FQID, {"enable_electronic_voting": False}) - response = self.request( - "poll.create", - { - "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - - def test_not_supported_pollmethod(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_SeVaiteYeiNgie5Xoov8", - "pollmethod": "not_existing", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - - def test_not_supported_onehundred_percent_base(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "invalid base", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - "data.onehundred_percent_base must be one of ['Y', 'YN', 'YNA', 'N', 'valid', 'cast', 'entitled', 'entitled_present', 'disabled']", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_wrong_pollmethod_onehundred_percent_base_combination_1(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "Y", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "This onehundred_percent_base not allowed in this pollmethod.", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_wrong_pollmethod_onehundred_percent_base_combination_2(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YN", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YNA", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "This onehundred_percent_base not allowed in this pollmethod.", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_wrong_pollmethod_onehundred_percent_base_combination_3(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "Y", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YNA", - "meeting_id": 1, - "options": [{"text": "test"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "This onehundred_percent_base not allowed in this pollmethod.", - response.json["message"], - ) - self.assert_model_not_exists("poll/1") - - def test_create_poll_for_option_with_wrong_content_object(self) -> None: - response = self.request( - "poll.create", - { - "meeting_id": 1, - "title": "Wahlgang (3)", - "onehundred_percent_base": "valid", - "pollmethod": "YN", - "type": "analog", - "options": [{"content_object_id": "assignment/1"}], - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "The collection 'assignment' is not available for field 'content_object_id' in collection 'option'.", - response.json["message"], - ) - - def test_unique_error_options_text(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "YNA", - "onehundred_percent_base": "valid", - "options": [ - {"text": "test", "Y": "10.000000"}, - {"text": "test", "A": "11.000000"}, - {"text": "test", "N": "12.000000"}, - ], - "meeting_id": 1, - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - "Duplicated option in poll.options: test", response.json["message"] - ) - - def test_unique_error_options_content_object_id(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "YNA", - "options": [ - {"content_object_id": "user/1", "Y": "10.000000"}, - {"text": "test4", "N": "11.000000"}, - {"content_object_id": "user/1", "Y": "11.000000"}, - ], - "meeting_id": 1, - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - "Duplicated option in poll.options: user/1", response.json["message"] - ) - - def test_unique_no_error_mixed_text_content_object_id_options(self) -> None: - self.set_user_groups(1, [1]) - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "YN", - "onehundred_percent_base": "valid", - "options": [ - { - "content_object_id": "user/1", - "Y": "10.000000", - "N": "5.000000", - }, - {"text": "text", "Y": "10.000000"}, - ], - "meeting_id": 1, - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 200) - - def test_analog_poll_without_YNA_values(self) -> None: - self.create_motion(1, 3) - self.set_models({"motion_state/1": {"allow_create_poll": True}}) - response = self.request( - "poll.create", - { - "meeting_id": 1, - "title": "Abstimmung", - "onehundred_percent_base": "YNA", - "pollmethod": "YNA", - "type": "analog", - "options": [{"content_object_id": "motion/3"}], - "content_object_id": "motion/3", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/1", - { - "content_object_id": "motion/3", - "vote_ids": [1, 2, 3], - "yes": Decimal("-2.000000"), - "no": Decimal("-2.000000"), - "abstain": Decimal("-2.000000"), - "weight": 1, - }, - ) - self.assert_model_exists( - "option/2", - { - "text": "global option", - "used_as_global_option_in_poll_id": 1, - "weight": 1, - }, - ) - self.assert_history_information("motion/3", ["Voting created"]) - - def test_not_state_change(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test1"}], - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"state": "created"}) - - def test_not_state_change_wrong_meeting(self) -> None: - self.create_meeting(4) - response = self.request( - "poll.create", - { - "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", - "type": "named", - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 4, - "options": [{"text": "test1"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "The following models do not belong to meeting 4: ['assignment/1']", - response.json["message"], - ) - - def test_create_user_option_valid(self) -> None: - self.set_user_groups(1, [1]) - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "YNA", - "options": [{"content_object_id": "user/1"}], - "meeting_id": 1, - "onehundred_percent_base": "YN", - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - {"option_ids": [1], "meeting_id": 1}, - ) - self.assert_model_exists( - "option/1", - {"content_object_id": "user/1", "poll_id": 1, "meeting_id": 1}, - ) - - def test_create_without_content_object(self) -> None: - response = self.request( - "poll.create", - { - "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", - "type": "named", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test1"}], - }, - ) - self.assert_status_code(response, 400) - self.assertEqual("No 'content_object_id' was given", response.json["message"]) - - def test_create_no_permissions_assignment(self) -> None: - self.base_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - ) - - def test_create_permissions_assignment(self) -> None: - self.base_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - Permissions.Assignment.CAN_MANAGE_POLLS, - ) - - def test_create_permissions_assignment_not_working_with_just_can_manage( - self, - ) -> None: - self.base_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - Permissions.Assignment.CAN_MANAGE, - fail=True, - ) - - def test_create_permissions_assignment_locked_meeting(self) -> None: - self.base_locked_out_superadmin_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - ) - - def test_create_forbidden_to_create_poll(self) -> None: - self.create_motion(1, 23) - self.set_models({"motion_state/1": {"allow_create_poll": False}}) - response = self.request( - "poll.create", - { - "meeting_id": 1, - "title": "Abstimmung", - "onehundred_percent_base": "YNA", - "pollmethod": "YNA", - "type": "analog", - "options": [{"content_object_id": "motion/23"}], - "content_object_id": "motion/23", - }, - ) - self.assert_status_code(response, 400) - self.assertEqual( - "Motion state doesn't allow to create poll.", response.json["message"] - ) - - def test_create_no_permissions_motion(self) -> None: - self.create_motion(1, 23) - self.base_permission_test( - {"motion_state/1": {"allow_create_poll": False}}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "motion/23", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - ) - - def test_create_permissions_motion(self) -> None: - self.create_motion(1, 23) - self.base_permission_test( - {"motion_state/1": {"allow_create_poll": True}}, - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "motion/23", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - }, - Permissions.Motion.CAN_MANAGE_POLLS, - ) - - def test_create_no_permissions_topic(self) -> None: - self.create_topic(13, 1) - self.base_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "content_object_id": "topic/1", - }, - ) - - def test_create_permissions_topic(self) -> None: - self.create_topic(13, 1) - self.base_permission_test( - {}, - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "content_object_id": "topic/1", - }, - ) - - def test_non_negative_max_votes_per_option(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "content_object_id": "assignment/1", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "global_yes": True, - "global_no": True, - "global_abstain": True, - "onehundred_percent_base": "Y", - "min_votes_amount": 5, - "max_votes_amount": 10, - "max_votes_per_option": -1, - "backend": "long", - }, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - - def test_max_votes_per_option_smaller_max_votes_amount(self) -> None: - """Also asserts that default values are respected.""" - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "max_votes_per_option": 2, - }, - ) - self.assert_status_code(response, 400) - assert ( - response.json["message"] - == "The maximum votes per option cannot be higher than the maximum amount of votes in total." - ) - self.assert_model_not_exists("poll/1") - - def test_max_votes_amount_smaller_min(self) -> None: - response = self.request( - "poll.create", - { - "title": "test", - "type": "analog", - "pollmethod": "Y", - "options": [{"text": "test2", "Y": "10.000000"}], - "meeting_id": 1, - "min_votes_amount": 5, - "max_votes_amount": 2, - "max_votes_per_option": 2, - }, - ) - self.assert_status_code(response, 400) - assert ( - response.json["message"] - == "The minimum amount of votes cannot be higher than the maximum amount of votes." - ) - self.assert_model_not_exists("poll/1") - - def test_create_poll_candidate_list(self) -> None: - self.set_models({"user/3": {"username": "User3"}}) - response = self.request( - "poll.create", - { - "title": "test", - "type": Poll.TYPE_NAMED, - "content_object_id": "assignment/1", - "pollmethod": "YNA", - "options": [{"poll_candidate_user_ids": [1, 3]}], - "meeting_id": 1, - "backend": "long", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("assignment/1", {"poll_ids": [1]}) - self.assert_model_exists( - "poll/1", - {"title": "test", "option_ids": [1], "content_object_id": "assignment/1"}, - ) - self.assert_model_exists( - "option/1", {"content_object_id": "poll_candidate_list/1", "poll_id": 1} - ) - self.assert_model_exists( - "poll_candidate_list/1", - {"option_id": 1, "meeting_id": 1, "poll_candidate_ids": [1, 2]}, - ) - self.assert_model_exists( - "poll_candidate/1", - {"user_id": 1, "weight": 1, "poll_candidate_list_id": 1, "meeting_id": 1}, - ) - self.assert_model_exists( - "poll_candidate/2", - {"user_id": 3, "weight": 2, "poll_candidate_list_id": 1, "meeting_id": 1}, - ) - self.assert_model_exists( - "meeting/1", {"poll_candidate_list_ids": [1], "poll_candidate_ids": [1, 2]} - ) - self.assert_model_exists( - "meeting/1", - { - "poll_ids": [1], - "option_ids": [1, 2], - "poll_candidate_ids": [1, 2], - "poll_candidate_list_ids": [1], - }, - ) - - def test_create_poll_candidate_lists(self) -> None: - self.set_models( - { - "user/2": {"username": "User2"}, - "user/3": {"username": "User3"}, - "user/4": {"username": "User4"}, - } - ) - response = self.request( - "poll.create", - { - "title": "test", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "content_object_id": "assignment/1", - "pollmethod": "YNA", - "options": [ - {"poll_candidate_user_ids": [1, 3]}, - {"poll_candidate_user_ids": [2, 4]}, - ], - "meeting_id": 1, - "backend": "long", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "title": "test", - "option_ids": [1, 2], - "content_object_id": "assignment/1", - }, - ) - self.assert_model_exists( - "option/1", {"content_object_id": "poll_candidate_list/1", "poll_id": 1} - ) - self.assert_model_exists( - "option/2", {"content_object_id": "poll_candidate_list/2", "poll_id": 1} - ) - self.assert_model_exists( - "poll_candidate_list/1", - {"option_id": 1, "meeting_id": 1, "poll_candidate_ids": [1, 2]}, - ) - self.assert_model_exists( - "poll_candidate_list/2", - {"option_id": 2, "meeting_id": 1, "poll_candidate_ids": [3, 4]}, - ) - - def test_with_anonymous_in_entitled_group_ids(self) -> None: - self.set_anonymous() - response = self.request( - "poll.create", - { - "meeting_id": 1, - "options": [{"text": "test"}], - "pollmethod": "YNA", - "title": "test", - "type": Poll.TYPE_NAMED, - "entitled_group_ids": [4], - "content_object_id": "assignment/1", - }, - ) - self.assert_status_code(response, 400) - self.assertIn( - "Anonymous group is not allowed in entitled_group_ids.", - response.json["message"], - ) - - def test_live_voting_named_motion_poll(self) -> None: - self.create_motion(1, 3) - self.set_models({"motion_state/1": {"allow_create_poll": True}}) - response = self.request( - "poll.create", - { - "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "content_object_id": "motion/3", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - "live_voting_enabled": True, - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", {"type": Poll.TYPE_NAMED, "live_voting_enabled": True} - ) - - def test_live_voting_named_assignment_poll(self) -> None: - self.base_test_live_voting_assignment() - - def test_live_voting_list_poll(self) -> None: - self.set_models({"user/3": {"username": "User3"}}) - self.base_test_live_voting_assignment( - { - "pollmethod": "YNA", - "options": [ - {"poll_candidate_user_ids": [1, 3]}, - ], - } - ) - - def test_live_voting_named_assignment_poll_pollmethod_yna(self) -> None: - self.base_test_live_voting_assignment({"pollmethod": "YNA"}) - - def test_live_voting_named_assignment_poll_pollmethod_yn(self) -> None: - self.base_test_live_voting_assignment({"pollmethod": "YN"}) - - def test_live_voting_named_assignment_poll_wrong_globalyes(self) -> None: - self.base_test_live_voting_assignment(error_dict={"global_yes": True}) - - def test_live_voting_named_assignment_poll_wrong_max_votes(self) -> None: - self.base_test_live_voting_assignment(error_dict={"max_votes_amount": 2}) - - def test_live_voting_named_assignment_poll_wrong_option_amount_yna(self) -> None: - self.base_test_live_voting_assignment( - error_dict={ - "pollmethod": "YNA", - "options": [ - {"text": "option1"}, - {"text": "option2"}, - ], - } - ) - - def test_live_voting_named_assignment_poll_wrong_option_amount_yn(self) -> None: - self.base_test_live_voting_assignment( - error_dict={ - "pollmethod": "YN", - "options": [ - {"text": "option1"}, - {"text": "option2"}, - ], - } - ) - - def base_test_live_voting_assignment( - self, - override_dict: dict[str, typing.Any] | None = None, - error_dict: dict[str, typing.Any] | None = None, - ) -> None: - self.create_assignment(3, 1) - - response = self.request( - "poll.create", - { - "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "Y", - "type": Poll.TYPE_NAMED, - "content_object_id": "assignment/3", - "onehundred_percent_base": "Y", - "meeting_id": 1, - "options": [{"text": "test"}], - "live_voting_enabled": True, - **(override_dict if override_dict else {}), - **(error_dict if error_dict else {}), - }, - ) - if not error_dict: - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", {"type": Poll.TYPE_NAMED, "live_voting_enabled": True} - ) - else: - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - assert ( - "live_voting_enabled only allowed for named motion polls and some named assignment polls." - ) in response.json["message"] - - def test_live_voting_not_allowed_type_analog(self) -> None: - self.base_live_voting_not_allowed(Poll.TYPE_ANALOG, True) - - def test_live_voting_not_allowed_type_pseudoanonymous(self) -> None: - self.base_live_voting_not_allowed(Poll.TYPE_PSEUDOANONYMOUS, True) - - def base_live_voting_not_allowed( - self, poll_type: str, is_motion_poll: bool - ) -> None: - request_data = { - "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", - "type": poll_type, - "content_object_id": "assignment/1", - "onehundred_percent_base": "YN", - "meeting_id": 1, - "options": [{"text": "test"}], - "live_voting_enabled": True, - } - if is_motion_poll: - self.create_motion(1, 3) - self.set_models({"motion_state/1": {"allow_create_poll": True}}) - request_data["content_object_id"] = "motion/3" - - response = self.request("poll.create", request_data) - self.assert_status_code(response, 400) - self.assert_model_not_exists("poll/1") - self.assertEqual( - "live_voting_enabled only allowed for named motion polls and some named assignment polls.", - response.json["message"], - ) diff --git a/tests/system/action/poll/test_delete.py b/tests/system/action/poll/test_delete.py deleted file mode 100644 index e0e25af65a..0000000000 --- a/tests/system/action/poll/test_delete.py +++ /dev/null @@ -1,178 +0,0 @@ -from unittest.mock import Mock, patch - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions -from openslides_backend.services.database.interface import PartialModel -from tests.system.util import CountDatastoreCalls, Profiler, performance - -from .base_poll_test import BasePollTestCase -from .poll_test_mixin import PollTestMixin - - -class PollDeleteTest(PollTestMixin, BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.poll_base_data = { - "title": "Poll 111", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_YN, - "meeting_id": 1, - "content_object_id": "motion/1", - "state": Poll.STATE_STARTED, - } - - def set_up_poll(self, poll_data: PartialModel = {}) -> None: - self.set_models({"poll/111": self.poll_base_data | poll_data}) - - @patch("openslides_backend.services.vote.adapter.VoteAdapter.clear") - def test_delete_correct(self, clear: Mock) -> None: - """ - Also ensures that the vote service is notified on success if the poll is started. - """ - clear_called_on: list[int] = [] - - def add_to_list(id_: int) -> None: - clear_called_on.append(id_) - - clear.side_effect = add_to_list - self.create_motion(1, 1) - self.set_up_poll() - response = self.request("poll.delete", {"id": 111}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll/111") - self.assert_history_information("motion/1", ["Voting deleted"]) - assert clear_called_on == [111] - - def test_delete_wrong_id(self) -> None: - self.create_motion(1, 1) - self.set_up_poll() - response = self.request("poll.delete", {"id": 110}) - self.assert_status_code(response, 400) - self.assert_model_exists("poll/111") - - @patch("openslides_backend.services.vote.adapter.VoteAdapter.clear") - def test_delete_correct_cascading(self, clear: Mock) -> None: - """ - Also ensures that the vote service is not notified if the poll wasn't started anyway. - """ - clear_called_on: list[int] = [] - - def add_to_list(id_: int) -> None: - clear_called_on.append(id_) - - clear.side_effect = add_to_list - self.create_topic(1, 1) - self.set_up_poll({"content_object_id": "topic/1", "state": Poll.STATE_CREATED}) - self.set_models( - { - "option/42": {"poll_id": 111, "meeting_id": 1}, - "projection/1": { - "content_object_id": "poll/111", - "current_projector_id": 1, - "meeting_id": 1, - }, - } - ) - response = self.request("poll.delete", {"id": 111}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll/111") - self.assert_model_not_exists("option/42") - self.assert_model_not_exists("projection/1") - self.assert_model_exists("projector/1", {"current_projection_ids": None}) - assert clear_called_on == [] - - def test_delete_cascading_poll_candidate_list(self) -> None: - self.create_meeting() - self.create_topic(1, 1) - self.set_up_poll({"content_object_id": "topic/1"}) - self.set_models( - { - "option/42": { - "poll_id": 111, - "meeting_id": 1, - "content_object_id": "poll_candidate_list/12", - }, - "poll_candidate_list/12": { - "meeting_id": 1, - "option_id": 42, - }, - "poll_candidate/13": { - "meeting_id": 1, - "user_id": 1, - "weight": 1, - "poll_candidate_list_id": 12, - }, - } - ) - response = self.request("poll.delete", {"id": 111}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll/111") - self.assert_model_not_exists("option/42") - self.assert_model_not_exists("poll_candidate_list/12") - - @patch("openslides_backend.services.vote.adapter.VoteAdapter.clear") - def test_delete_no_permissions(self, clear: Mock) -> None: - """ - Also ensures that the vote service is not notified if the action failed. - """ - clear_called_on: list[int] = [] - - def add_to_list(id_: int) -> None: - clear_called_on.append(id_) - - clear.side_effect = add_to_list - self.create_topic(1, 1) - self.set_up_poll({"content_object_id": "topic/1"}) - self.base_permission_test( - {}, - "poll.delete", - {"id": 111}, - ) - - assert clear_called_on == [] - - def test_delete_permissions(self) -> None: - self.create_topic(1, 1) - self.set_up_poll({"content_object_id": "topic/1"}) - self.base_permission_test( - {}, - "poll.delete", - {"id": 111}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_delete_permissions_locked_meeting(self) -> None: - self.create_topic(1, 1) - self.set_up_poll({"content_object_id": "topic/1"}) - self.base_locked_out_superadmin_permission_test( - {}, - "poll.delete", - {"id": 111}, - ) - - def test_delete_datastore_calls(self) -> None: - self.prepare_users_and_poll(3) - - with CountDatastoreCalls() as counter: - response = self.request("poll.delete", {"id": 1}) - - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll/1") - assert counter.calls == 16 - - @performance - def test_delete_performance(self) -> None: - # TODO this needs a different idea - user_ids = self.prepare_users_and_poll(1000) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.datastore.reset(hard=True) - - with Profiler("test_delete_performance.prof"): - response = self.request("poll.delete", {"id": 1}) - - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll["voted_ids"] == user_ids diff --git a/tests/system/action/poll/test_publish.py b/tests/system/action/poll/test_publish.py deleted file mode 100644 index 25609ec6cf..0000000000 --- a/tests/system/action/poll/test_publish.py +++ /dev/null @@ -1,98 +0,0 @@ -from decimal import Decimal -from typing import Any - -from openslides_backend.permissions.permissions import Permissions - -from .base_poll_test import BasePollTestCase - - -class PollPublishActionTest(BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.create_topic(1, 1) - self.poll_data: dict[str, dict[str, Any]] = { - "poll/1": { - "type": "named", - "pollmethod": "Y", - "backend": "long", - "state": "finished", - "meeting_id": 1, - "content_object_id": "topic/1", - "title": "Poll 1", - "onehundred_percent_base": "YNA", - }, - } - - def test_publish_correct(self) -> None: - self.set_models(self.poll_data) - response = self.request("poll.publish", {"id": 1}) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll.get("state") == "published" - self.assert_history_information("topic/1", None) - - def test_publish_motion(self) -> None: - self.create_motion(1, 2) - self.poll_data["poll/1"]["content_object_id"] = "motion/2" - self.set_models(self.poll_data) - response = self.request("poll.publish", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("motion/2", ["Voting published"]) - - def test_publish_assignment(self) -> None: - self.create_assignment(1, 1) - self.poll_data["poll/1"]["content_object_id"] = "assignment/1" - self.set_models(self.poll_data) - response = self.request("poll.publish", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("assignment/1", ["Ballot published"]) - - def test_publish_wrong_state(self) -> None: - self.poll_data["poll/1"]["state"] = "created" - self.set_models(self.poll_data) - response = self.request("poll.publish", {"id": 1}) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - assert poll.get("state") == "created" - self.assertEqual( - "Cannot publish poll 1, because it is not in state finished or started.", - response.json["message"], - ) - - def test_publish_started(self) -> None: - self.create_motion(1, 2) - self.poll_data["poll/1"]["content_object_id"] = "motion/2" - self.poll_data["poll/1"]["state"] = "started" - self.set_models(self.poll_data) - self.vote_service.start(1) - response = self.request("poll.publish", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "votescast": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votesvalid": Decimal("0.000000"), - "entitled_users_at_stop": [], - }, - ) - self.assert_history_information("motion/2", ["Voting stopped/published"]) - - def test_publish_no_permissions(self) -> None: - self.base_permission_test(self.poll_data, "poll.publish", {"id": 1}) - - def test_publish_permissions(self) -> None: - self.base_permission_test( - self.poll_data, - "poll.publish", - {"id": 1}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_publish_permissions_locked_meeting(self) -> None: - self.base_locked_out_superadmin_permission_test( - self.poll_data, - "poll.publish", - {"id": 1}, - ) diff --git a/tests/system/action/poll/test_reset.py b/tests/system/action/poll/test_reset.py deleted file mode 100644 index 12d8b18279..0000000000 --- a/tests/system/action/poll/test_reset.py +++ /dev/null @@ -1,200 +0,0 @@ -from decimal import Decimal -from typing import Any - -from psycopg.types.json import Jsonb - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions -from tests.system.util import CountDatastoreCalls, Profiler, performance - -from .base_poll_test import BasePollTestCase -from .poll_test_mixin import PollTestMixin - - -class PollResetActionTest(PollTestMixin, BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.test_models: dict[str, dict[str, Any]] = { - "poll/1": { - "title": "Poll 1", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_YN, - "state": Poll.STATE_STARTED, - "global_option_id": 2, - "meeting_id": 1, - "content_object_id": "topic/1", - }, - "option/1": {"poll_id": 1, "meeting_id": 1}, - "option/2": {"meeting_id": 1}, - "vote/1": { - "option_id": 1, - "meeting_id": 1, - "user_token": "abs", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "vote/2": { - "option_id": 1, - "meeting_id": 1, - "user_token": "def", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "vote/3": { - "option_id": 2, - "meeting_id": 1, - "user_token": "ghi", - "weight": Decimal("1.000000"), - "value": "Y", - }, - } - - def test_reset_correct(self) -> None: - self.create_topic(1, 1) - self.test_models["poll/1"].update( - { - "votesvalid": "3.000000", - "votesinvalid": "1.000000", - "votescast": "5.000000", - "entitled_users_at_stop": Jsonb([{"user_id": 1, "voted": True}]), - } - ) - self.set_models(self.test_models) - - response = self.request("poll.reset", {"id": 1}) - self.assert_status_code(response, 200) - - # check if the state has been changed to 1 (Created). - poll = self.get_model("poll/1") - assert poll.get("state") == "created" - - # check if not is_pseudoanonymized - assert poll.get("is_pseudoanonymized") is False - - # check if voted_ids is cleared - assert poll.get("voted_ids") is None - - # check if auto generated fields are cleared - assert poll.get("entitled_users_at_stop") is None - assert poll.get("votesvalid") is None - assert poll.get("votesinvalid") is None - assert poll.get("votescast") is None - - # check if the votes are deleted - self.assert_model_not_exists("vote/1") - self.assert_model_not_exists("vote/2") - self.assert_model_not_exists("vote/3") - - # check if the option.vote_ids fields are cleared - self.assert_model_exists( - "option/1", - { - "vote_ids": None, - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "vote_ids": None, - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - # test history - self.assert_history_information("topic/1", None) - - def test_reset_motion(self) -> None: - self.create_motion(1, 1) - self.test_models["poll/1"]["content_object_id"] = "motion/1" - self.set_models(self.test_models) - response = self.request("poll.reset", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("motion/1", ["Voting reset"]) - - def test_reset_assignment(self) -> None: - self.create_assignment(1, 1) - self.test_models["poll/1"]["content_object_id"] = "assignment/1" - self.set_models(self.test_models) - response = self.request("poll.reset", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("assignment/1", ["Ballot reset"]) - - def test_reset_no_permissions(self) -> None: - self.create_topic(1, 1) - self.base_permission_test(self.test_models, "poll.reset", {"id": 1}) - - def test_reset_permissions(self) -> None: - self.create_topic(1, 1) - self.base_permission_test( - self.test_models, - "poll.reset", - {"id": 1}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_reset_permissions_locked_meeting(self) -> None: - self.create_topic(1, 1) - self.base_locked_out_superadmin_permission_test( - self.test_models, - "poll.reset", - {"id": 1}, - ) - - def test_reset_not_allowed_to_vote_again(self) -> None: - self.create_topic(1, 1) - self.test_models["poll/1"].update( - { - "pollmethod": "Y", - "max_votes_per_option": 1, - "backend": "long", - "onehundred_percent_base": "Y", - } - ) - self.test_models["group/1"] = {"poll_ids": [1]} - self.set_models(self.test_models) - self.set_user_groups(1, [1]) - self.set_models({"meeting/1": {"present_user_ids": [1]}}) - self.vote_service.start(1) - response = self.vote_service.vote({"id": 1, "value": {"1": 1}}) - self.assert_status_code(response, 200) - response = self.request("poll.reset", {"id": 1}) - self.assert_status_code(response, 200) - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - response = self.vote_service.vote({"id": 1, "value": {"1": 1}}) - self.assert_status_code(response, 200) - - def test_reset_datastore_calls(self) -> None: - self.prepare_users_and_poll(3) - - with CountDatastoreCalls() as counter: - response = self.request("poll.reset", {"id": 1}) - - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", {"voted_ids": None, "state": Poll.STATE_CREATED} - ) - assert counter.calls == 14 - - @performance - def test_reset_performance(self) -> None: - # TODO this needs a different idea - self.prepare_users_and_poll(100) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.datastore.reset(hard=True) - - with Profiler("test_reset_performance.prof"): - response = self.request("poll.reset", {"id": 1}) - - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", {"voted_ids": [], "state": Poll.STATE_CREATED} - ) diff --git a/tests/system/action/poll/test_set_state.py b/tests/system/action/poll/test_set_state.py deleted file mode 100644 index 4d0a4d74f0..0000000000 --- a/tests/system/action/poll/test_set_state.py +++ /dev/null @@ -1,32 +0,0 @@ -from decimal import Decimal - -from .base_poll_test import BasePollTestCase - - -class PollSetState(BasePollTestCase): - def test_set_state(self) -> None: - self.create_meeting(110) - self.create_assignment(1, 110) - self.set_models( - { - "poll/65": { - "title": "Title 65", - "type": "analog", - "state": "created", - "pollmethod": "YNA", - "meeting_id": 110, - "content_object_id": "assignment/1", - }, - "option/57": { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - "meeting_id": 110, - "poll_id": 65, - }, - } - ) - response = self.request("poll.set_state", {"id": 65, "state": "finished"}) - self.assert_status_code(response, 200) - poll = self.get_model("poll/65") - assert poll.get("state") == "finished" diff --git a/tests/system/action/poll/test_start.py b/tests/system/action/poll/test_start.py deleted file mode 100644 index c155d66538..0000000000 --- a/tests/system/action/poll/test_start.py +++ /dev/null @@ -1,226 +0,0 @@ -from decimal import Decimal -from typing import Any - -from openslides_backend.models.models import Poll - -from .base_poll_test import BasePollTestCase - - -class VotePollBaseTestClass(BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting(meeting_data={"poll_couple_countdown": True}) - self.create_assignment(1, 1) - self.set_user_groups(1, [1]) - self.set_models( - { - "meeting/1": {"present_user_ids": [1], "poll_countdown_id": 11}, - "projector_countdown/11": { - "title": "Countdown 11", - "default_time": 60, - "running": False, - "countdown_time": 60, - "meeting_id": 1, - }, - "group/1": {"poll_ids": [1]}, - "option/1": {"meeting_id": 1, "poll_id": 1}, - "option/2": {"meeting_id": 1, "poll_id": 1}, - "poll/1": { - "content_object_id": "assignment/1", - "title": "test_title_04k0y4TwPLpJKaSvIGm1", - "state": Poll.STATE_CREATED, - "meeting_id": 1, - "votesinvalid": Decimal("0.000000"), - "votesvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - "backend": "fast", - **self.get_poll_data(), - "onehundred_percent_base": "YNA", - }, - } - ) - - def get_poll_data(self) -> dict[str, Any]: - # has to be implemented by subclasses - raise NotImplementedError() - - -class VotePollAnalogYNA(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "YNA", - "type": Poll.TYPE_ANALOG, - } - - def test_start_analog_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 400) - assert ( - "Analog polls cannot be started. Please use poll.update instead to give votes." - in response.json["message"] - ) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("state"), Poll.STATE_CREATED) - - -class VotePollNamedYNA(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") - # test history - self.assert_history_information("assignment/1", ["Ballot started"]) - # test that votes can be given - response = self.vote_service.vote({"id": 1, "value": {"1": "Y"}}) - self.assert_status_code(response, 200) - - def test_start_motion_poll(self) -> None: - self.create_motion(1, 1) - self.set_models( - { - "group/1": {"poll_ids": [1, 2]}, - "option/3": {"meeting_id": 1, "poll_id": 2}, - "option/4": {"meeting_id": 1, "poll_id": 2}, - "poll/2": { - "content_object_id": "motion/1", - "title": "motion poll 2", - "state": Poll.STATE_CREATED, - "meeting_id": 1, - "votesinvalid": Decimal("0.000000"), - "votesvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - "backend": "fast", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "onehundred_percent_base": "YNA", - }, - } - ) - response = self.request("poll.start", {"id": 2}) - self.assert_status_code(response, 200) - self.assert_history_information("motion/1", ["Voting started"]) - - -class VotePollNamedY(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "Y", - "type": Poll.TYPE_NAMED, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") - - -class VotePollNamedN(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "N", - "type": Poll.TYPE_NAMED, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoanonymousYNA(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "YNA", - "type": Poll.TYPE_PSEUDOANONYMOUS, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoanonymousY(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "Y", - "type": Poll.TYPE_PSEUDOANONYMOUS, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoAnonymousN(VotePollBaseTestClass): - def get_poll_data(self) -> dict[str, Any]: - return { - "pollmethod": "N", - "type": Poll.TYPE_PSEUDOANONYMOUS, - } - - def test_start_poll(self) -> None: - response = self.request("poll.start", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "state": Poll.STATE_STARTED, - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("0.000000"), - }, - ) - self.assert_model_not_exists("vote/1") diff --git a/tests/system/action/poll/test_stop.py b/tests/system/action/poll/test_stop.py deleted file mode 100644 index c121052324..0000000000 --- a/tests/system/action/poll/test_stop.py +++ /dev/null @@ -1,370 +0,0 @@ -from decimal import Decimal -from typing import Any -from unittest.mock import Mock, patch - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions -from tests.system.util import CountDatastoreCalls, Profiler, performance - -from .base_poll_test import BasePollTestCase -from .poll_test_mixin import PollTestMixin - - -class PollStopActionTest(PollTestMixin, BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.poll_data: dict[str, Any] = { - "type": Poll.TYPE_NAMED, - "pollmethod": "Y", - "backend": "fast", - "content_object_id": "topic/1", - "state": Poll.STATE_STARTED, - "meeting_id": 1, - "title": "Poll 1", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_Y, - } - - @patch("openslides_backend.services.vote.adapter.VoteAdapter.clear") - def test_stop_correct(self, clear: Mock) -> None: - clear_called_on: list[int] = [] - - def add_to_list(id_: int) -> None: - clear_called_on.append(id_) - - clear.side_effect = add_to_list - self.create_meeting() - self.create_motion(1, 1) - user1 = self.create_user_for_meeting(1) - user2 = self.create_user_for_meeting(1) - user3 = self.create_user_for_meeting(1) - self.set_models( - { - "poll/1": { - **self.poll_data, - "content_object_id": "motion/1", - "pollmethod": "YN", - "entitled_group_ids": [1], - }, - "group/1": {"poll_ids": [1]}, - "option/1": {"meeting_id": 1, "poll_id": 1}, - "meeting/1": { - "users_enable_vote_weight": True, - "poll_couple_countdown": True, - "poll_countdown_id": 1, - "users_enable_vote_delegations": True, - "present_user_ids": [user1, user2], - }, - "projector_countdown/1": { - "title": "Countdown 1", - "running": True, - "default_time": 60, - "countdown_time": 30.0, - "meeting_id": 1, - }, - f"user/{user1}": {"default_vote_weight": Decimal("2.000000")}, - f"user/{user2}": {"default_vote_weight": Decimal("3.000000")}, - "meeting_user/1": {"vote_weight": Decimal("2.600000")}, - "meeting_user/2": {"vote_weight": Decimal("3.600000")}, - "meeting_user/3": { - "vote_weight": Decimal("4.600000"), - "vote_delegated_to_id": 1, - }, - } - ) - self.start_poll(1) - self.login(user1) - response = self.vote_service.vote({"id": 1, "value": {"1": "Y"}}) - self.assert_status_code(response, 200) - response = self.vote_service.vote( - {"id": 1, "user_id": user3, "value": {"1": "N"}} - ) - self.assert_status_code(response, 200) - - self.login(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "projector_countdown/1", - { - "running": False, - "countdown_time": 60, - }, - ) - self.assert_model_exists( - "poll/1", - { - "voted_ids": [2, 4], - "state": Poll.STATE_FINISHED, - "votescast": Decimal("2.000000"), - "votesinvalid": Decimal("0.000000"), - "votesvalid": Decimal("7.200000"), - "entitled_users_at_stop": [ - { - "voted": True, - "present": True, - "user_id": user1, - "vote_delegated_to_user_id": None, - }, - { - "voted": False, - "present": True, - "user_id": user2, - "vote_delegated_to_user_id": None, - }, - { - "voted": True, - "present": False, - "user_id": user3, - "vote_delegated_to_user_id": user1, - }, - ], - }, - ) - # test history - self.assert_history_information("motion/1", ["Voting stopped"]) - assert clear_called_on == [1] - - def test_stop_assignment_poll(self) -> None: - self.create_meeting() - self.create_assignment(1, 1) - self.set_models( - { - "poll/1": { - **self.poll_data, - "content_object_id": "assignment/1", - "pollmethod": "YN", - }, - } - ) - self.start_poll(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_history_information("assignment/1", ["Ballot stopped"]) - - def test_stop_entitled_users_at_stop_user_only_once(self) -> None: - self.create_meeting() - self.create_motion(1, 1) - self.create_user("User2") - self.set_user_groups(2, [2, 3]) - self.set_models( - { - "poll/1": { - **self.poll_data, - "content_object_id": "motion/1", - "entitled_group_ids": [3, 4], - }, - "meeting/1": {"present_user_ids": [2]}, - "group/2": {"poll_ids": [1]}, - "group/3": {"poll_ids": [1]}, - } - ) - self.start_poll(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "entitled_users_at_stop": [ - { - "voted": False, - "present": True, - "user_id": 2, - "vote_delegated_to_user_id": None, - }, - ] - }, - ) - - def test_stop_entitled_users_not_present(self) -> None: - self.create_meeting() - self.create_motion(1, 1) - user2 = self.create_user_for_meeting(1) - user3 = self.create_user_for_meeting(1) - self.set_user_groups(user2, [1]) - self.set_user_groups(user3, [3]) - self.set_models( - { - "poll/1": {**self.poll_data, "content_object_id": "motion/1"}, - "group/1": {"poll_ids": [1]}, - } - ) - self.start_poll(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "entitled_users_at_stop": [ - { - "voted": False, - "present": False, - "user_id": 2, - "vote_delegated_to_user_id": None, - }, - ] - }, - ) - - def test_stop_entitled_users_with_delegations(self) -> None: - self.create_meeting(meeting_data={"users_enable_vote_delegations": True}) - self.create_motion(1, 1) - user2 = self.create_user_for_meeting(1) - user3 = self.create_user_for_meeting(1) - self.set_user_groups(user2, [1]) - self.set_user_groups(user3, [3]) - self.set_models( - { - "poll/1": {**self.poll_data, "content_object_id": "motion/1"}, - "group/1": {"poll_ids": [1]}, - "meeting_user/1": {"vote_delegated_to_id": 2}, - } - ) - self.start_poll(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "entitled_users_at_stop": [ - { - "voted": False, - "present": False, - "user_id": 2, - "vote_delegated_to_user_id": 3, - }, - ] - }, - ) - - def test_stop_entitled_users_with_delegations_turned_off(self) -> None: - self.create_meeting(meeting_data={"users_enable_vote_delegations": False}) - self.create_motion(1, 1) - user2 = self.create_user_for_meeting(1) - user3 = self.create_user_for_meeting(1) - self.set_user_groups(user2, [1]) - self.set_user_groups(user3, [3]) - self.set_models( - { - "poll/1": {**self.poll_data, "content_object_id": "motion/1"}, - "group/1": {"poll_ids": [1]}, - "meeting_user/1": {"vote_delegated_to_id": 2}, - } - ) - self.start_poll(1) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "entitled_users_at_stop": [ - { - "voted": False, - "present": False, - "user_id": 2, - "vote_delegated_to_user_id": None, - }, - ] - }, - ) - - def test_stop_published(self) -> None: - self.create_meeting() - self.create_motion(1, 1) - self.set_models( - { - "poll/1": { - **self.poll_data, - "content_object_id": "motion/1", - "state": Poll.STATE_PUBLISHED, - }, - } - ) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - assert poll.get("state") == Poll.STATE_PUBLISHED - assert ( - "Cannot stop poll 1, because it is not in state started." - in response.json["message"] - ) - - def test_stop_created(self) -> None: - self.create_meeting() - self.create_motion(1, 1) - self.set_models( - { - "poll/1": { - **self.poll_data, - "content_object_id": "motion/1", - "state": Poll.STATE_CREATED, - } - } - ) - response = self.request("poll.stop", {"id": 1}) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - assert poll.get("state") == Poll.STATE_CREATED - assert ( - "Cannot stop poll 1, because it is not in state started." - in response.json["message"] - ) - - @patch("openslides_backend.services.vote.adapter.VoteAdapter.clear") - def test_stop_no_permissions(self, clear: Mock) -> None: - clear_called_on: list[int] = [] - - def add_to_list(id_: int) -> None: - clear_called_on.append(id_) - - clear.side_effect = add_to_list - self.create_meeting() - self.create_topic(1, 1) - self.set_models({"poll/1": self.poll_data}) - self.start_poll(1) - self.base_permission_test({}, "poll.stop", {"id": 1}) - - assert clear_called_on == [] - - def test_stop_permissions(self) -> None: - self.create_meeting() - self.create_topic(1, 1) - self.set_models({"poll/1": self.poll_data}) - self.start_poll(1) - self.base_permission_test( - {}, - "poll.stop", - {"id": 1}, - Permissions.Poll.CAN_MANAGE, - ) - - def test_stop_permissions_locked_meeting(self) -> None: - self.create_meeting() - self.create_topic(1, 1) - self.set_models({"poll/1": self.poll_data}) - self.start_poll(1) - self.base_locked_out_superadmin_permission_test( - {}, - "poll.stop", - {"id": 1}, - ) - - def test_stop_datastore_calls(self) -> None: - user_ids = self.prepare_users_and_poll(3) - - with CountDatastoreCalls() as counter: - response = self.request("poll.stop", {"id": 1}) - - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"voted_ids": user_ids}) - assert counter.calls == 60 - - @performance - def test_stop_performance(self) -> None: - user_ids = self.prepare_users_and_poll(3) - - with Profiler("test_stop_performance.prof"): - response = self.request("poll.stop", {"id": 1}) - - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll["voted_ids"] == user_ids diff --git a/tests/system/action/poll/test_update.py b/tests/system/action/poll/test_update.py deleted file mode 100644 index 141ba9c934..0000000000 --- a/tests/system/action/poll/test_update.py +++ /dev/null @@ -1,810 +0,0 @@ -from decimal import Decimal -from typing import Any - -from psycopg.types.json import Jsonb - -from openslides_backend.models.models import Poll -from openslides_backend.permissions.permissions import Permissions - -from .base_poll_test import BasePollTestCase - - -class UpdatePollTestCase(BasePollTestCase): - def setUp(self) -> None: - super().setUp() - self.entitled_users_at_stop_data: list[dict[str, Any]] = [ - { - "voted": True, - "present": True, - "user_id": 2, - "vote_delegated_to_user_id": None, - }, - { - "voted": True, - "present": False, - "user_id": 3, - "vote_delegated_to_user_id": 2, - }, - ] - - self.create_meeting() - self.create_assignment(1, 1, {"open_posts": 1}) - self.set_models( - { - "meeting/1": {"present_user_ids": [1]}, - "group/1": {"poll_ids": [1], "meeting_user_ids": [11]}, - "poll/1": { - "content_object_id": "assignment/1", - "title": "test_title_beeFaihuNae1vej2ai8m", - "pollmethod": "Y", - "type": Poll.TYPE_NAMED, - "onehundred_percent_base": "Y", - "state": Poll.STATE_CREATED, - "meeting_id": 1, - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - }, - "option/1": {"meeting_id": 1, "poll_id": 1}, - "option/2": {"meeting_id": 1, "poll_id": 1}, - "meeting_user/11": {"user_id": 1, "meeting_id": 1}, - } - ) - - def test_catch_not_allowed(self) -> None: - self.update_model("poll/1", {"state": "started"}) - response = self.request( - "poll.update", - { - "id": 1, - "pollmethod": "Y", - "type": "analog", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "global_yes": False, - "global_no": True, - "global_abstain": True, - }, - ) - self.assert_status_code(response, 400) - assert ("data must not contain {'type'} properties") in response.json["message"] - - def test_optional_state_created(self) -> None: - response = self.request( - "poll.update", - { - "id": 1, - "pollmethod": "Y", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "global_yes": False, - "global_no": True, - "global_abstain": True, - }, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll.get("pollmethod") == "Y" - assert poll.get("min_votes_amount") == 1 - assert poll.get("max_votes_amount") == 1 - assert poll.get("max_votes_per_option") == 1 - assert poll.get("global_yes") is False - assert poll.get("global_no") is True - assert poll.get("global_abstain") is True - self.assert_history_information("assignment/1", ["Ballot updated"]) - - def test_not_allowed_for_analog(self) -> None: - self.update_model("poll/1", {"type": "analog"}) - response = self.request("poll.update", {"id": 1, "entitled_group_ids": []}) - self.assert_status_code(response, 400) - assert ( - "Following options are not allowed in this state and type: " - "entitled_group_ids" - ) in response.json["message"] - - def test_not_allowed_for_non_analog(self) -> None: - response = self.request( - "poll.update", - { - "id": 1, - "votesvalid": "10.000000", - "votesinvalid": "11.000000", - "votescast": "3.000000", - "publish_immediately": True, - }, - ) - self.assert_status_code(response, 400) - assert ( - "Following options are not allowed in this state and type: " - "votesvalid, votesinvalid, votescast, publish_immediately" - ) in response.json["message"] - - def test_update_title(self) -> None: - response = self.request( - "poll.update", - {"title": "test_title_Aishohh1ohd0aiSut7gi", "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("title"), "test_title_Aishohh1ohd0aiSut7gi") - - def test_prevent_updating_content_object(self) -> None: - self.create_assignment(2, 1, {"open_posts": 1}) - response = self.request( - "poll.update", - {"content_object_id": "assignment/2", "id": 1}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("content_object_id"), "assignment/1") # unchanged - - def test_update_pollmethod(self) -> None: - response = self.request( - "poll.update", - {"pollmethod": "YNA", "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("pollmethod"), "YNA") - self.assertEqual(poll.get("onehundred_percent_base"), "Y") - - def test_update_backend(self) -> None: - response = self.request( - "poll.update", - {"backend": "long", "id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"backend": "long"}) - - def test_update_backend_not_allowed(self) -> None: - self.set_models({"poll/1": {"state": Poll.STATE_FINISHED}}) - response = self.request( - "poll.update", - {"backend": "long", "id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - "Following options are not allowed in this state and type: backend" - in response.json["message"] - ) - - def test_update_invalid_pollmethod(self) -> None: - response = self.request( - "poll.update", - {"pollmethod": "invalid"}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("pollmethod"), "Y") - - def test_update_groups_to_empty(self) -> None: - response = self.request("poll.update", {"entitled_group_ids": [], "id": 1}) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll.get("entitled_group_ids") is None - - def test_update_groups(self) -> None: - response = self.request( - "poll.update", - {"entitled_group_ids": [2], "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("entitled_group_ids"), [2]) - - def test_update_groups_with_anonymous(self) -> None: - group_id = self.set_anonymous() - response = self.request( - "poll.update", - {"entitled_group_ids": [group_id], "id": 1}, - ) - self.assert_status_code(response, 400) - self.assertIn( - "Anonymous group is not allowed in entitled_group_ids.", - response.json["message"], - ) - - def test_update_title_started(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_STARTED}) - response = self.request( - "poll.update", - {"title": "test_title_Oophah8EaLaequu3toh8", "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("title"), "test_title_Oophah8EaLaequu3toh8") - - def test_update_wrong_state(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_STARTED}) - response = self.request( - "poll.update", - {"type": Poll.TYPE_NAMED, "id": 1}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("type"), Poll.TYPE_NAMED) - - def test_update_max_votes_per_option(self) -> None: - response = self.request( - "poll.update", - {"max_votes_per_option": 5, "max_votes_amount": 5, "id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("poll/1", {"max_votes_per_option": 5}) - - def test_max_votes_per_option_smaller_max_votes_amount(self) -> None: - response = self.request( - "poll.update", - {"max_votes_per_option": 5, "max_votes_amount": 1, "id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - response.json["message"] - == "The maximum votes per option cannot be higher than the maximum amount of votes in total." - ) - self.assert_model_exists("poll/1", {"max_votes_per_option": 1}) - - def test_max_votes_amount_smaller_min(self) -> None: - response = self.request( - "poll.update", - {"min_votes_amount": 5, "max_votes_amount": 1, "id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - response.json["message"] - == "The minimum amount of votes cannot be higher than the maximum amount of votes." - ) - self.assert_model_exists("poll/1", {"min_votes_amount": 1}) - - def test_update_negative_fields(self) -> None: - for field in ("max_votes_per_option", "max_votes_amount", "min_votes_amount"): - response = self.request( - "poll.update", - {field: -3, "id": 1}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get(field), 1) - - def test_update_100_percent_base(self) -> None: - response = self.request( - "poll.update", - {"onehundred_percent_base": "cast", "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("onehundred_percent_base"), "cast") - - def test_update_wrong_100_percent_base(self) -> None: - response = self.request( - "poll.update", - {"onehundred_percent_base": "invalid", "id": 1}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("onehundred_percent_base"), "Y") - - def test_update_multiple_fields(self) -> None: - response = self.request( - "poll.update", - { - "id": 1, - "title": "test_title_ees6Tho8ahheen4cieja", - "pollmethod": "Y", - "global_yes": True, - "global_no": True, - "global_abstain": False, - "max_votes_per_option": 2, - "max_votes_amount": 2, - }, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("title"), "test_title_ees6Tho8ahheen4cieja") - self.assertEqual(poll.get("pollmethod"), "Y") - self.assertTrue(poll.get("global_yes")) - self.assertTrue(poll.get("global_no")) - self.assertFalse(poll.get("global_abstain")) - self.assertEqual(poll.get("max_votes_per_option"), 2) - - def test_update_max_votes_per_option_state_not_created(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_STARTED}) - response = self.request( - "poll.update", - {"max_votes_per_option": 3, "id": 1}, - ) - self.assert_status_code(response, 400) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("max_votes_per_option"), 1) - - def test_update_100_percent_base_state_not_created(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_STARTED}) - response = self.request( - "poll.update", - {"onehundred_percent_base": "cast", "id": 1}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("onehundred_percent_base"), "cast") - - def test_update_wrong_100_percent_base_state_not_created(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_STARTED, "pollmethod": "YN"}) - response = self.request( - "poll.update", - {"onehundred_percent_base": "YNA", "id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - "This onehundred_percent_base not allowed in this pollmethod" - in response.json["message"] - ) - poll = self.get_model("poll/1") - self.assertEqual(poll.get("onehundred_percent_base"), "Y") - - def test_update_wrong_100_percent_base_entitled_and_analog(self) -> None: - self.update_model( - "poll/1", - {"state": Poll.STATE_STARTED, "pollmethod": "YN", "type": Poll.TYPE_ANALOG}, - ) - response = self.request( - "poll.update", - {"onehundred_percent_base": "entitled", "id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - "onehundred_percent_base: value entitled is not allowed for analog." - in response.json["message"] - ) - - def test_state_change(self) -> None: - self.update_model("poll/1", {"type": Poll.TYPE_ANALOG}) - response = self.request("poll.update", {"id": 1, "votescast": "1.000000"}) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll.get("state") == Poll.STATE_FINISHED - - def test_state_change_2_published(self) -> None: - self.update_model("poll/1", {"type": Poll.TYPE_ANALOG}) - response = self.request( - "poll.update", - {"id": 1, "votescast": "1.000000", "publish_immediately": True}, - ) - self.assert_status_code(response, 200) - poll = self.get_model("poll/1") - assert poll.get("state") == Poll.STATE_PUBLISHED - assert "publish_immediately" not in poll - - def test_default_vote_values(self) -> None: - self.update_model("poll/1", {"type": Poll.TYPE_ANALOG}) - response = self.request( - "poll.update", - {"id": 1, "votescast": "1.000000"}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "votescast": Decimal("1.000000"), - "votesvalid": Decimal("-2.000000"), - "votesinvalid": Decimal("-2.000000"), - }, - ) - - def test_motion_history_information(self) -> None: - self.create_motion(1, 2) - self.set_models( - { - "poll/3": { - "title": "motion poll", - "pollmethod": "Y", - "onehundred_percent_base": "Y", - "state": Poll.STATE_CREATED, - "type": Poll.TYPE_NAMED, - "meeting_id": 1, - "content_object_id": "motion/2", - }, - } - ) - response = self.request( - "poll.update", - {"id": 3, "title": "test"}, - ) - self.assert_status_code(response, 200) - self.assert_history_information("motion/2", ["Voting updated"]) - - def test_update_no_permissions(self) -> None: - self.base_permission_test( - {}, - "poll.update", - {"title": "test_title_Aishohh1ohd0aiSut7gi", "id": 1}, - ) - - def test_update_permissions(self) -> None: - self.base_permission_test( - {}, - "poll.update", - {"title": "test_title_Aishohh1ohd0aiSut7gi", "id": 1}, - Permissions.Assignment.CAN_MANAGE_POLLS, - ) - - def test_update_wrong_permissions(self) -> None: - self.base_permission_test( - {}, - "poll.update", - {"title": "test_title_Aishohh1ohd0aiSut7gi", "id": 1}, - Permissions.Assignment.CAN_MANAGE, - fail=True, - ) - - def test_update_permissions_locked_meeting(self) -> None: - self.base_locked_out_superadmin_permission_test( - {}, - "poll.update", - {"title": "test_title_Aishohh1ohd0aiSut7gi", "id": 1}, - ) - - def test_update_entitled_users_at_stop_error(self) -> None: - response = self.request( - "poll.update", - {"entitled_users_at_stop": self.entitled_users_at_stop_data, "id": 1}, - internal=False, - ) - self.assert_status_code(response, 400) - assert ( - "data must not contain {'entitled_users_at_stop'} properties" - in response.json["message"] - ) - - def test_update_entitled_users_at_stop_fields_changed_error(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - { - "entitled_users_at_stop": [ - {**self.entitled_users_at_stop_data[0], "voted": False}, - self.entitled_users_at_stop_data[1], - ], - "id": 1, - }, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "Can not change essential 'entitled_users_at_stop' data via poll.update" - in response.json["message"] - ) - - def test_update_entitled_users_at_stop_initial_set_attempt_error(self) -> None: - response = self.request( - "poll.update", - {"entitled_users_at_stop": self.entitled_users_at_stop_data, "id": 1}, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "Can not set 'entitled_users_at_stop' via poll.update" - in response.json["message"] - ) - - def test_update_entitled_users_at_stop_list_shortened_error(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - {"entitled_users_at_stop": [self.entitled_users_at_stop_data[1]], "id": 1}, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "Can not change essential 'entitled_users_at_stop' data via poll.update" - in response.json["message"] - ) - - def test_update_entitled_users_at_stop_list_lengthened_error(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - { - "entitled_users_at_stop": [ - *self.entitled_users_at_stop_data, - {"voted": True, "present": True, "user_id": 5}, - ], - "id": 1, - }, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "Can not change essential 'entitled_users_at_stop' data via poll.update" - in response.json["message"] - ) - - def test_update_entitled_users_at_stop_wrong_format_error(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - {"entitled_users_at_stop": {"this": "shouldn't be a dict"}, "id": 1}, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "'entitled_users_at_stop' has the wrong format" in response.json["message"] - ) - - def test_update_entitled_users_at_stop_wrong_format_error_2(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - {"entitled_users_at_stop": ["this shouldn't be a string"], "id": 1}, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "'entitled_users_at_stop' has the wrong format" in response.json["message"] - ) - - def test_update_entitled_users_at_stop_wrong_format_error_3(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - { - "entitled_users_at_stop": [{"this": "is still the wrong format"}], - "id": 1, - }, - internal=True, - ) - self.assert_status_code(response, 400) - assert ( - "'entitled_users_at_stop' has the wrong format" in response.json["message"] - ) - - def test_update_entitled_users_at_stop_nothing_changed(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - {"entitled_users_at_stop": self.entitled_users_at_stop_data, "id": 1}, - internal=True, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - {"entitled_users_at_stop": self.entitled_users_at_stop_data}, - ) - - def test_update_entitled_users_at_stop_fields_changed_success(self) -> None: - self.set_models( - { - "poll/1": { - "entitled_users_at_stop": Jsonb(self.entitled_users_at_stop_data) - } - } - ) - response = self.request( - "poll.update", - { - "entitled_users_at_stop": [ - { - **self.entitled_users_at_stop_data[0], - "delegation_user_merged_into_id": 9, - "user_merged_into_id": 10, - }, - self.entitled_users_at_stop_data[1], - ], - "id": 1, - }, - internal=True, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll/1", - { - "entitled_users_at_stop": [ - { - **self.entitled_users_at_stop_data[0], - "delegation_user_merged_into_id": 9, - "user_merged_into_id": 10, - }, - self.entitled_users_at_stop_data[1], - ] - }, - ) - - def test_live_voting_named_motion_poll(self) -> None: - self.base_test_live_voting(Poll.TYPE_NAMED, True, True) - - def test_live_voting_named_assignment_poll(self) -> None: - self.base_test_live_voting(Poll.TYPE_NAMED, True, False) - - def test_live_voting_named_list_poll(self) -> None: - self.set_models( - { - "user/2": {"username": "User2"}, - "poll_candidate/1": { - "id": 1, - "weight": 1, - "user_id": 1, - "meeting_id": 1, - "poll_candidate_list_id": 1, - }, - "poll_candidate/2": { - "id": 2, - "weight": 1, - "user_id": 2, - "meeting_id": 1, - "poll_candidate_list_id": 2, - }, - "poll_candidate_list/1": { - "id": 1, - "option_id": 3, - "meeting_id": 1, - "poll_candidate_ids": [1], - }, - "poll_candidate_list/2": { - "id": 2, - "option_id": 4, - "meeting_id": 1, - "poll_candidate_ids": [2], - }, - "option/3": { - "id": 3, - "poll_id": 2, - "meeting_id": 1, - "content_object_id": "poll_candidate_list/1", - }, - "option/4": { - "id": 4, - "poll_id": 2, - "meeting_id": 1, - "content_object_id": "poll_candidate_list/2", - }, - "poll/2": { - "content_object_id": "assignment/1", - "title": "test_live_voting_named_list_poll", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "onehundred_percent_base": "Y", - "state": Poll.STATE_CREATED, - "meeting_id": 1, - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - }, - } - ) - self.base_test_live_voting(Poll.TYPE_NAMED, True, False, poll_id=2) - - def test_live_voting_named_assignment_poll_one_option_yna(self) -> None: - self.base_test_live_voting( - Poll.TYPE_NAMED, - True, - False, - {"content_object_id": "assignment/1"}, - poll_id=2, - ) - - def test_live_voting_named_assignment_poll_one_option_yn(self) -> None: - self.base_test_live_voting( - Poll.TYPE_NAMED, - True, - False, - {"content_object_id": "assignment/1"}, - poll_id=2, - ) - - def test_live_voting_wrong_type_analog(self) -> None: - self.base_test_live_voting(Poll.TYPE_ANALOG, False, True) - - def test_live_voting_wrong_type_pseudoanonymous(self) -> None: - self.base_test_live_voting(Poll.TYPE_PSEUDOANONYMOUS, False, True) - - def test_live_voting_assignment_poll_wrong_pollmethod_yna(self) -> None: - self.base_test_live_voting(Poll.TYPE_NAMED, False, False, {"pollmethod": "YNA"}) - - def test_live_voting_assignment_poll_wrong_pollmethod_yn(self) -> None: - self.base_test_live_voting(Poll.TYPE_NAMED, False, False, {"pollmethod": "YN"}) - - def test_live_voting_assignment_poll_wrong_global_yes(self) -> None: - self.base_test_live_voting(Poll.TYPE_NAMED, False, False, {"global_yes": True}) - - def test_live_voting_assignment_poll_wrong_max_votes_amount(self) -> None: - self.base_test_live_voting( - Poll.TYPE_NAMED, False, False, {"max_votes_amount": 2} - ) - - def base_test_live_voting( - self, - poll_type: str, - is_allowed: bool, - is_motion_poll: bool, - poll_changes: dict[str, Any] = {}, - poll_id: int = 1, - ) -> None: - poll_data: dict[str, Any] = {"type": poll_type, **poll_changes} - - if poll_id == 1 and ( - "content_object_id" not in poll_changes - or poll_changes["content_object_id"] == "assignment/1" - ): - # Avoid creating new poll unless specific poll_id other than 1 is needed - # and content_object_id is equal to poll/1/content_object_id - self.update_model("poll/1", poll_data) - poll_id = 1 - else: - if is_motion_poll: - self.create_motion(1, 3) - self.set_models({"motion_state/1": {"allow_create_poll": True}}) - poll_data["content_object_id"] = "motion/3" - if poll_id == 1: - poll_id = 2 # Default if no custom poll_id is provided - self.set_models( - { - **({"group/1": {"poll_ids": [1, poll_id]}} if poll_id != 1 else {}), - f"poll/{poll_id}": { - "title": "motion live voting", - "pollmethod": "Y", - "onehundred_percent_base": "Y", - "state": Poll.STATE_CREATED, - "meeting_id": 1, - **poll_data, - }, - "option/3": {"meeting_id": 1, "poll_id": poll_id}, - "option/4": {"meeting_id": 1, "poll_id": poll_id}, - } - ) - - response = self.request( - "poll.update", {"id": poll_id, "live_voting_enabled": True} - ) - self.assert_status_code(response, 200 if is_allowed else 400) - self.assert_model_exists(f"poll/{poll_id}", {"live_voting_enabled": is_allowed}) - if is_allowed: - assert ( - "live_voting_enabled only allowed for named motion polls and some named assignment polls." - ) not in response.json["message"] - else: - assert ( - "live_voting_enabled only allowed for named motion polls and some named assignment polls." - ) in response.json["message"] diff --git a/tests/system/action/poll/test_vote.py b/tests/system/action/poll/test_vote.py deleted file mode 100644 index 6fb3834dec..0000000000 --- a/tests/system/action/poll/test_vote.py +++ /dev/null @@ -1,2164 +0,0 @@ -from decimal import Decimal -from typing import Any - -import requests -import simplejson as json - -from openslides_backend.models.models import Poll -from openslides_backend.services.database.interface import PartialModel -from tests.system.util import convert_to_test_response -from tests.util import Response - -from .base_poll_test import BasePollTestCase - - -class BaseVoteTestCase(BasePollTestCase): - def request( - self, - action: str, - data: dict[str, Any], - anonymous: bool = False, - lang: str | None = None, - internal: bool | None = None, - start_poll_before_vote: bool = True, - stop_poll_after_vote: bool = True, - ) -> Response: - """Overwrite request method to reroute voting requests to the vote service.""" - if action == "poll.vote": - if start_poll_before_vote: - self.execute_action_internally("poll.start", {"id": data["id"]}) - response = self.vote_service.vote(data) - if stop_poll_after_vote: - # TODO: fix execute_action_internally to avoid concurrent update error - # self.execute_action_internally("poll.stop", {"id": data["id"]}) - self.request("poll.stop", {"id": data["id"]}, internal=True) - return response - else: - return super().request(action, data, anonymous, lang, internal) - - def anonymous_vote(self, payload: dict[str, Any], id: int = 1) -> Response: - # make request manually to prevent sending of cookie & header - payload_json = json.dumps(payload, separators=(",", ":")) - response = requests.post( - url=self.vote_service.url.replace("internal", "system") + f"?id={id}", - data=payload_json, - headers={ - "Content-Type": "application/json", - }, - ) - return convert_to_test_response(response) - - -class PollVoteTest(BaseVoteTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting(113) - self.create_motion(113, 1) - - def create_poll_with_3_options(self, poll_data: PartialModel = {}) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "poll_id": 1}, - "option/12": {"meeting_id": 113, "poll_id": 1}, - "option/13": {"meeting_id": 113, "poll_id": 1}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "pollmethod": "YN", - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "YNA", - **poll_data, - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - } - ) - - def test_vote_correct_pollmethod_Y(self) -> None: - user_id = self.create_user("test2") - self.set_models( - { - "group/113": {"meeting_user_ids": [11, 12], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "poll_id": 1}, - "meeting_user/11": { - "meeting_id": 113, - "user_id": 1, - }, - "meeting_user/12": { - "meeting_id": 113, - "user_id": user_id, - "vote_weight": Decimal("2.000000"), - }, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "pollmethod": "Y", - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "YNA", - }, - "meeting/113": { - "users_enable_vote_weight": True, - "present_user_ids": [1, 2], - }, - } - ) - response = self.request( - "poll.vote", {"id": 1, "value": {"11": 1}}, stop_poll_after_vote=False - ) - self.assert_status_code(response, 200) - self.login(user_id) - response = self.request( - "poll.vote", {"id": 1, "value": {"11": 1}}, start_poll_before_vote=False - ) - self.assert_status_code(response, 200) - for i in range(1, 3): - vote = self.assert_model_exists( - f"vote/{i}", {"value": "Y", "option_id": 11, "meeting_id": 113} - ) - user_id = vote.get("user_id", 0) - assert user_id == vote.get("delegated_user_id") - self.assert_model_exists( - f"user/{user_id}", - { - "poll_voted_ids": [1], - "delegated_vote_ids": [i], - "vote_ids": [vote["id"]], - }, - ) - assert vote.get("weight") == Decimal(f"{user_id}.000000") - self.assert_model_exists( - "option/11", - { - "vote_ids": [1, 2], - "yes": Decimal("3.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_value_check(self) -> None: - self.create_poll_with_3_options( - { - "min_votes_amount": 1, - "max_votes_amount": 10, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "Y", "12": "N", "13": "A"}, - }, - ) - self.assert_status_code(response, 400) - assert ( - "Data for option 13 does not fit the poll method." - in response.json["message"] - ) - - def test_vote_correct_pollmethod_YN(self) -> None: - self.create_poll_with_3_options( - { - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "Y", "12": "N"}, - }, - ) - self.assert_status_code(response, 200) - vote = self.assert_model_exists( - "vote/1", - { - "value": "Y", - "option_id": 11, - "weight": Decimal("1.000000"), - "meeting_id": 113, - "user_id": 1, - "delegated_user_id": 1, - }, - ) - user_token = vote.get("user_token") - vote = self.assert_model_exists( - "vote/2", - { - "value": "N", - "option_id": 12, - "weight": Decimal("1.000000"), - "meeting_id": 113, - "user_id": 1, - "delegated_user_id": 1, - }, - ) - assert vote.get("user_token") == user_token - self.assert_model_exists( - "option/11", - { - "vote_ids": [1], - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/12", - { - "vote_ids": [2], - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_vote_wrong_votes_total(self) -> None: - self.create_poll_with_3_options( - { - "pollmethod": "Y", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": 1, "12": 1}, - }, - ) - self.assert_status_code(response, 400) - assert ( - "The sum of your answers has to be between 1 and 1" - in response.json["message"] - ) - self.assert_model_not_exists("vote/1") - - def test_vote_pollmethod_Y_wrong_value(self) -> None: - self.create_poll_with_3_options({"pollmethod": "Y"}) - response = self.request( - "poll.vote", - { - "id": 1, - "value": {"11": "Y"}, - }, - ) - self.assert_status_code(response, 400) - assert "Your vote has a wrong format" in response.json["message"] - self.assert_model_not_exists("vote/1") - - def test_vote_no_votes_total_check_by_YNA(self) -> None: - self.create_poll_with_3_options( - { - "pollmethod": "YNA", - "min_votes_amount": 1, - "max_votes_amount": 2, - "max_votes_per_option": 1, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "Y", "12": "A"}, - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - - def test_vote_no_votes_total_check_by_YNA_max_votes_error(self) -> None: - self.create_poll_with_3_options( - { - "pollmethod": "YNA", - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "Y", "12": "A"}, - }, - ) - self.assert_status_code(response, 400) - assert "You have to select between 1 and 1 options" in response.json["message"] - - def test_vote_no_votes_total_check_by_YN(self) -> None: - self.create_poll_with_3_options( - {"max_votes_amount": 10, "max_votes_per_option": 1} - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "Y", "12": "N"}, - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - - def test_vote_wrong_votes_total_min_case(self) -> None: - self.create_poll_with_3_options( - { - "pollmethod": "Y", - "min_votes_amount": 2, - "max_votes_amount": 2, - "max_votes_per_option": 1, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": 1}, - }, - ) - self.assert_status_code(response, 400) - assert ( - "The sum of your answers has to be between 2 and 2" - in response.json["message"] - ) - self.assert_model_not_exists("vote/1") - - def test_vote_global(self) -> None: - self.create_user("test2") - self.set_models( - { - "meeting/113": {"present_user_ids": [1, 2]}, - "group/113": {"meeting_user_ids": [11, 12], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "used_as_global_option_in_poll_id": 1}, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "meeting_user/12": {"user_id": 2, "meeting_id": 113}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "global_option_id": 11, - "global_no": True, - "global_yes": False, - "global_abstain": False, - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "pollmethod": "YNA", - "backend": "fast", - "type": "named", - "onehundred_percent_base": "Y", - }, - } - ) - response = self.request( - "poll.vote", - {"id": 1, "user_id": 1, "value": "N"}, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 200) - response = self.request( - "poll.vote", - {"id": 1, "user_id": 2, "value": "Y"}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - - self.assert_model_exists( - "vote/1", - { - "value": "N", - "option_id": 11, - "weight": Decimal("1.000000"), - "meeting_id": 113, - "user_id": 1, - }, - ) - self.assert_model_exists( - "option/11", - { - "vote_ids": [1], - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "user/1", - { - "poll_voted_ids": [1], - "delegated_vote_ids": [1], - "vote_ids": [1], - }, - ) - self.assert_model_not_exists("vote/2") - - def test_vote_schema_problems(self) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "meeting_id": 113, - "pollmethod": "YNA", - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "YNA", - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - } - ) - response = self.request("poll.vote", {"id": 1, "user_id": 1, "value": "X"}) - self.assert_status_code(response, 400) - assert "Global vote X is not enabled" in response.json["message"] - - def test_vote_invalid_vote_value(self) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "poll_id": 1}, - "poll/1": { - "content_object_id": "motion/1", - "option_ids": [11], - "pollmethod": "YNA", - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "backend": "fast", - "type": "named", - "title": "Poll 1", - "onehundred_percent_base": "YNA", - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - } - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"11": "X"}, - }, - ) - self.assert_status_code(response, 400) - assert ( - "Data for option 11 does not fit the poll method." - in response.json["message"] - ) - - def test_vote_not_started_in_service(self) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "poll/1": { - "content_object_id": "motion/1", - "type": "named", - "meeting_id": 113, - "pollmethod": "YNA", - "global_yes": True, - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "title": "Poll 1", - "onehundred_percent_base": "YNA", - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - } - ) - response = self.request( - "poll.vote", - {"id": 1, "value": "Y"}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - assert "Poll does not exist" in response.json["message"] - - def test_vote_option_not_in_poll(self) -> None: - self.create_poll_with_3_options( - {"pollmethod": "YNA", "onehundred_percent_base": "Y"} - ) - response = self.request( - "poll.vote", - { - "id": 1, - "user_id": 1, - "value": {"113": "Y"}, - }, - ) - self.assert_status_code(response, 400) - assert "Option_id 113 does not belong to the poll" in response.json["message"] - - def test_double_vote(self) -> None: - self.create_user("test2") - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11, 12], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "used_as_global_option_in_poll_id": 1}, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "meeting_user/12": {"user_id": 2, "meeting_id": 113}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "global_option_id": 11, - "global_no": True, - "global_yes": False, - "global_abstain": False, - "meeting_id": 113, - "pollmethod": "YN", - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "Y", - }, - } - ) - response = self.request( - "poll.vote", - {"id": 1, "user_id": 1, "value": "N"}, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 200) - response = self.request( - "poll.vote", - {"id": 1, "user_id": 1, "value": "N"}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - assert "Not the first vote" in response.json["message"] - self.assert_model_exists( - "vote/1", - { - "value": "N", - "option_id": 11, - "weight": Decimal("1.000000"), - "meeting_id": 113, - "user_id": 1, - "delegated_user_id": 1, - }, - ) - self.assert_model_exists("option/11", {"vote_ids": [1]}) - self.assert_model_exists( - "user/1", - {"poll_voted_ids": [1], "vote_ids": [1], "delegated_vote_ids": [1]}, - ) - - def test_check_user_in_entitled_group(self) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11]}, - "option/11": {"meeting_id": 113, "used_as_global_option_in_poll_id": 1}, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "poll/1": { - "content_object_id": "motion/1", - "pollmethod": "YNA", - "global_option_id": 11, - "global_no": True, - "global_yes": False, - "global_abstain": False, - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "title": "Poll 1", - "onehundred_percent_base": "YNA", - }, - } - ) - response = self.request("poll.vote", {"id": 1, "user_id": 1, "value": "N"}) - self.assert_status_code(response, 400) - assert "User 1 is not allowed to vote" in response.json["message"] - - def test_check_user_present_in_meeting(self) -> None: - self.set_models( - { - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "option/11": {"meeting_id": 113, "used_as_global_option_in_poll_id": 1}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "global_option_id": 11, - "global_no": True, - "global_yes": False, - "global_abstain": False, - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "backend": "fast", - "type": "named", - "pollmethod": "YNA", - "onehundred_percent_base": "Y", - }, - } - ) - response = self.request("poll.vote", {"id": 1, "value": "N"}) - self.assert_status_code(response, 400) - assert "You have to be present in meeting 113" in response.json["message"] - - def test_check_str_validation(self) -> None: - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "type": "named", - "meeting_id": 113, - "pollmethod": "Y", - "state": Poll.STATE_CREATED, - "backend": "fast", - "onehundred_percent_base": "Y", - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - } - ) - response = self.request("poll.vote", {"id": 1, "user_id": 1, "value": "X"}) - self.assert_status_code(response, 400) - assert "Global vote X is not enabled" in response.json["message"] - - def test_default_vote_weight(self) -> None: - self.set_models( - { - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "poll_id": 1}, - "user/1": {"default_vote_weight": Decimal("3.000000")}, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "pollmethod": "Y", - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "max_votes_per_option": 1, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "Y", - }, - "meeting/113": { - "users_enable_vote_weight": True, - "present_user_ids": [1], - }, - } - ) - response = self.request( - "poll.vote", {"id": 1, "user_id": 1, "value": {"11": 1}} - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "vote/1", - { - "value": "Y", - "option_id": 11, - "weight": Decimal("3.000000"), - "meeting_id": 113, - "user_id": 1, - }, - ) - self.assert_model_exists( - "option/11", - { - "vote_ids": [1], - "yes": Decimal("3.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "user/1", - {"poll_voted_ids": [1], "delegated_vote_ids": [1], "vote_ids": [1]}, - ) - - def test_vote_weight_not_enabled(self) -> None: - self.set_models( - { - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "option/11": {"meeting_id": 113, "poll_id": 1}, - "user/1": {"default_vote_weight": Decimal("3.000000")}, - "meeting_user/11": { - "meeting_id": 113, - "user_id": 1, - "vote_weight": Decimal("4.200000"), - }, - "poll/1": { - "content_object_id": "motion/1", - "title": "my test poll", - "pollmethod": "Y", - "meeting_id": 113, - "state": Poll.STATE_CREATED, - "max_votes_per_option": 1, - "backend": "fast", - "type": "named", - "onehundred_percent_base": "Y", - }, - "meeting/113": { - "users_enable_vote_weight": False, - "present_user_ids": [1], - }, - } - ) - response = self.request( - "poll.vote", {"id": 1, "user_id": 1, "value": {"11": 1}} - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "vote/1", - { - "value": "Y", - "option_id": 11, - "weight": Decimal("1.000000"), - "meeting_id": 113, - "user_id": 1, - }, - ) - self.assert_model_exists( - "option/11", - { - "vote_ids": [1], - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "user/1", - {"poll_voted_ids": [1], "delegated_vote_ids": [1], "vote_ids": [1]}, - ) - - -class VotePollBaseTestClass(BaseVoteTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting(113) - self.create_assignment(1, 113, {"open_posts": 1}) - self.create_poll() - self.set_models( - { - "meeting/113": {"present_user_ids": [1]}, - "group/113": {"meeting_user_ids": [11], "poll_ids": [1]}, - "option/1": { - "meeting_id": 113, - "poll_id": 1, - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - "option/2": { - "meeting_id": 113, - "poll_id": 1, - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - "meeting_user/11": {"user_id": 1, "meeting_id": 113}, - "option/11": {"meeting_id": 113}, - "poll/1": {"global_option_id": 11}, - } - ) - - def create_poll(self) -> None: - # has to be implemented by subclasses - raise NotImplementedError() - - def start_poll(self) -> None: - self.update_model("poll/1", {"state": Poll.STATE_CREATED}) - - def add_option(self) -> None: - self.set_models( - { - "option/3": {"meeting_id": 113, "poll_id": 1}, - } - ) - - -class VotePollNamedYNA(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_OkHAIvOSIcpFnCxbaL6v", - "pollmethod": "YNA", - "type": Poll.TYPE_NAMED, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votescast": Decimal("0.000000"), - "votesvalid": Decimal("0.000000"), - "votesinvalid": Decimal("0.000000"), - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "YNA", - }, - ) - - def test_vote(self) -> None: - self.add_option() - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "2": "N", "3": "A"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_count("vote", 113, 3) - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/3", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("1.000000"), - }, - ) - - def test_vote_with_voteweight(self) -> None: - self.set_models( - { - "meeting_user/11": {"vote_weight": "4.200000"}, - "meeting/113": {"users_enable_vote_weight": True}, - } - ) - self.add_option() - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "2": "N", "3": "A"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_count("vote", 113, 3) - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("4.200000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "state": Poll.STATE_FINISHED, - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("4.200000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("4.200000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/3", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("4.200000"), - }, - ) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 200) - response = self.request( - "poll.vote", - {"value": {"1": "N"}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/2") - vote = self.get_model("vote/1") - self.assertEqual(vote.get("value"), "Y") - - def test_too_many_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "3": "N"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "3": "N"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_anonymous(self) -> None: - self.start_poll() - response = self.anonymous_vote({"value": {"1": "Y"}}) - self.assert_status_code(response, 401) - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_missing_data(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": {}, "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - poll = self.get_model("poll/1") - self.assertNotIn(1, poll.get("voted_ids", [])) - - def test_wrong_data_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": [1, 2, 5], "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"id": "Y"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_data(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": [None]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - -class VotePollNamedY(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_Zrvh146QAdq7t6iSDwZk", - "pollmethod": "Y", - "type": Poll.TYPE_NAMED, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "global_yes": True, - "global_no": True, - "global_abstain": True, - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - self.assert_model_not_exists("vote/2") - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 200) - response = self.request( - "poll.vote", - {"value": {"1": 0, "2": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_yes(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "Y", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_yes_forbidden(self) -> None: - self.update_model("poll/1", {"global_yes": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "Y", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_global_no(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "N", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_no_forbidden(self) -> None: - self.update_model("poll/1", {"global_no": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "N", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_global_abstain(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "A", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("1.000000"), - }, - ) - - def test_global_abstain_forbidden(self) -> None: - self.update_model("poll/1", {"global_abstain": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "A", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_negative_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": -1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_too_many_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 1, "3": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"id": 1, "value": {"3": 1}}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_anonymous(self) -> None: - self.start_poll() - response = self.anonymous_vote({"value": {"1": 1}}) - self.assert_status_code(response, 401) - self.assert_model_not_exists("vote/1") - - def test_anonymous_as_vote_user(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 0}, - ) - self.assert_status_code(response, 400) - assert "Votes for anonymous user are not allowed" in response.json["message"] - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_missing_data(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": {}, "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - poll = self.get_model("poll/1") - self.assertNotIn(1, poll.get("voted_ids", [])) - - def test_wrong_data_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": [1, 2, 5], "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"id": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_data(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": [None]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - -class VotePollYMaxVotesPerOption(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_Zrvh146QAASDfVeidq7t6iSDwZk", - "pollmethod": "Y", - "type": Poll.TYPE_NAMED, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "global_yes": False, - "global_no": False, - "global_abstain": False, - "min_votes_amount": 1, - "max_votes_amount": 5, - "max_votes_per_option": 3, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 2, "2": 3}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("2.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("3.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 3}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": 2, "2": 0}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("3.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_vote_weight(self) -> None: - self.update_model("user/1", {"default_vote_weight": Decimal("3.000000")}) - self.update_model("meeting/113", {"users_enable_vote_weight": True}) - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 3}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("3.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("9.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_vote_change_weight(self) -> None: - self.update_model("user/1", {"default_vote_weight": Decimal("3.000000")}) - self.update_model("meeting/113", {"users_enable_vote_weight": True}) - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 2, "2": 0}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": 0, "2": 3}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("6.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - -class VotePollNamedN(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_4oi49ckKFk39SDIfj30s", - "pollmethod": "N", - "type": Poll.TYPE_NAMED, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "global_yes": True, - "global_no": True, - "global_abstain": True, - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - self.assert_model_not_exists("vote/2") - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_change_vote(self) -> None: - self.add_option() - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": 0, "2": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_yes(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "Y", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_yes_forbidden(self) -> None: - self.update_model("poll/1", {"global_yes": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "Y", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_global_no(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "N", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_global_no_forbidden(self) -> None: - self.update_model("poll/1", {"global_no": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "N", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_global_abstain(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": "A", "id": 1, "user_id": 1}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "option/11", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("1.000000"), - }, - ) - - def test_global_abstain_forbidden(self) -> None: - self.update_model("poll/1", {"global_abstain": False}) - self.start_poll() - response = self.request("poll.vote", {"value": "A", "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_negative_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": -1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"3": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_anonymous(self) -> None: - self.start_poll() - response = self.anonymous_vote({"value": {"1": 1}}) - self.assert_status_code(response, 401) - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_missing_data(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": {}, "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - poll = self.get_model("poll/1") - self.assertNotIn(1, poll.get("voted_ids", [])) - - def test_wrong_data_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": [1, 2, 5], "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"id": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_data(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": [None]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoanonymousYNA(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_OkHAIvOSIcpFnCxbaL6v", - "pollmethod": "YNA", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.add_option() - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "2": "N", "3": "A"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_count("vote", 113, 3) - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/3", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("1.000000"), - }, - ) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": "N"}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_too_many_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "3": "N"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_partial_vote(self) -> None: - self.add_option() - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - - def test_wrong_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "Y", "3": "N"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_anonymous(self) -> None: - self.start_poll() - response = self.anonymous_vote({"value": {"1": "Y"}}) - self.assert_status_code(response, 401) - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - response = self.request( - "poll.vote", - {"value": {"1": "Y"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_missing_value(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": {}, "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - poll = self.get_model("poll/1") - self.assertNotIn(1, poll.get("voted_ids", [])) - - def test_wrong_value_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": [1, 2, 5], "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"id": "Y"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_value(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": [None]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoanonymousY(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_Zrvh146QAdq7t6iSDwZk", - "pollmethod": "Y", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - self.assert_model_not_exists("vote/2") - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists("vote/1", {"user_id": None}) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": 0, "2": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.get_model("poll/1") - self.assert_model_exists( - "option/1", - { - "yes": Decimal("1.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_negative_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": -1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_options(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"3": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_missing_data(self) -> None: - self.start_poll() - response = self.request("poll.vote", {"value": {}, "id": 1, "user_id": 1}) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - poll = self.get_model("poll/1") - self.assertNotIn(1, poll.get("voted_ids", [])) - - def test_wrong_data_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"value": [1, 2, 5]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"id": 1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_data(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": [None]}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - -class VotePollPseudoanonymousN(VotePollBaseTestClass): - def create_poll(self) -> None: - self.create_model( - "poll/1", - { - "content_object_id": "assignment/1", - "title": "test_title_wWPOVJgL9afm83eamf3e", - "pollmethod": "N", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "state": Poll.STATE_CREATED, - "meeting_id": 113, - "votesinvalid": Decimal("0.000000"), - "min_votes_amount": 1, - "max_votes_amount": 10, - "max_votes_per_option": 1, - "onehundred_percent_base": "Y", - }, - ) - - def test_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"id": 1, "value": {"1": 1, "2": 0}, "user_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists("vote/1") - self.assert_model_not_exists("vote/2") - self.assert_model_exists( - "poll/1", - { - "votesvalid": Decimal("1.000000"), - "votesinvalid": Decimal("0.000000"), - "votescast": Decimal("1.000000"), - "voted_ids": [1], - }, - ) - self.assert_model_exists( - "option/1", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists("vote/1", {"user_id": None}) - - def test_change_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": 1, "2": 0}, "id": 1, "user_id": 1}, - stop_poll_after_vote=False, - ) - response = self.request( - "poll.vote", - {"value": {"1": 0, "2": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - ) - self.assert_status_code(response, 400) - self.get_model("poll/1") - self.assert_model_exists( - "option/1", - { - "yes": Decimal("0.000000"), - "no": Decimal("1.000000"), - "abstain": Decimal("0.000000"), - }, - ) - self.assert_model_exists( - "option/2", - { - "yes": Decimal("0.000000"), - "no": Decimal("0.000000"), - "abstain": Decimal("0.000000"), - }, - ) - - def test_negative_vote(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": -1}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_vote_not_present(self) -> None: - self.start_poll() - self.update_model("meeting/113", {"present_user_ids": None}) - - response = self.request( - "poll.vote", - {"id": 1, "user_id": 1, "value": {"1": 1}}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_state(self) -> None: - response = self.request( - "poll.vote", - {"value": {"1": 1}, "id": 1, "user_id": 1}, - start_poll_before_vote=False, - stop_poll_after_vote=False, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_data_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": [1, 2, 5], "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - assert ( - "decoding payload: unknown vote value: `[1,2,5]`" - in response.json["message"] - ) - self.assert_model_not_exists("vote/1") - - def test_wrong_option_format(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"value": {"1": "string"}, "id": 1, "user_id": 1}, - ) - self.assert_status_code(response, 400) - assert "Your vote has a wrong format" in response.json["message"] - self.assert_model_not_exists("vote/1") - - def test_wrong_option_id_type(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"id": 1}, - ) - self.assert_status_code(response, 400) - self.assert_model_not_exists("vote/1") - - def test_wrong_vote_data(self) -> None: - self.start_poll() - response = self.request( - "poll.vote", - {"id": 1, "value": {"1": [None]}, "user_id": 1}, - ) - self.assert_status_code(response, 400) - assert "decoding payload: unknown vote value:" in response.json["message"] - self.assert_model_not_exists("vote/1") diff --git a/tests/system/action/poll_candidate/__init__.py b/tests/system/action/poll_candidate/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/system/action/poll_candidate/test_create.py b/tests/system/action/poll_candidate/test_create.py deleted file mode 100644 index 24abbb79b5..0000000000 --- a/tests/system/action/poll_candidate/test_create.py +++ /dev/null @@ -1,27 +0,0 @@ -from tests.system.action.base import BaseActionTestCase - - -class PollCandidateTest(BaseActionTestCase): - def test_create(self) -> None: - self.create_meeting() - self.set_models( - { - "poll_candidate_list/2": {"meeting_id": 1}, - "option/1": { - "meeting_id": 1, - "content_object_id": "poll_candidate_list/2", - }, - } - ) - response = self.request( - "poll_candidate.create", - {"user_id": 1, "poll_candidate_list_id": 2, "weight": 12, "meeting_id": 1}, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll_candidate/1", - {"meeting_id": 1, "user_id": 1, "poll_candidate_list_id": 2, "weight": 12}, - ) - self.assert_model_exists("user/1", {"poll_candidate_ids": [1]}) - self.assert_model_exists("meeting/1", {"poll_candidate_ids": [1]}) - self.assert_model_exists("poll_candidate_list/2", {"poll_candidate_ids": [1]}) diff --git a/tests/system/action/poll_candidate/test_delete.py b/tests/system/action/poll_candidate/test_delete.py deleted file mode 100644 index df2d336c87..0000000000 --- a/tests/system/action/poll_candidate/test_delete.py +++ /dev/null @@ -1,27 +0,0 @@ -from tests.system.action.base import BaseActionTestCase - - -class PollCandidateDeleteTest(BaseActionTestCase): - def test_delete_correct(self) -> None: - self.create_meeting() - self.set_models( - { - "poll_candidate_list/2": {"meeting_id": 1}, - "option/1": { - "meeting_id": 1, - "content_object_id": "poll_candidate_list/2", - }, - "poll_candidate/3": { - "meeting_id": 1, - "poll_candidate_list_id": 2, - "user_id": 1, - "weight": 1, - }, - } - ) - response = self.request("poll_candidate.delete", {"id": 3}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll_candidate/3") - self.assert_model_exists("user/1", {"poll_candidate_ids": None}) - self.assert_model_exists("poll_candidate_list/2", {"poll_candidate_ids": None}) - self.assert_model_exists("meeting/1", {"poll_candidate_ids": None}) diff --git a/tests/system/action/poll_candidate_list/__init__.py b/tests/system/action/poll_candidate_list/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/system/action/poll_candidate_list/test_create.py b/tests/system/action/poll_candidate_list/test_create.py deleted file mode 100644 index 6a42ea0da0..0000000000 --- a/tests/system/action/poll_candidate_list/test_create.py +++ /dev/null @@ -1,38 +0,0 @@ -from tests.system.action.base import BaseActionTestCase - - -class PollCandidateList(BaseActionTestCase): - def test_create_correct(self) -> None: - self.create_meeting() - self.create_user("test1") - self.create_user("test2") - self.set_models({"option/4": {"meeting_id": 1}}) - response = self.request( - "poll_candidate_list.create", - { - "option_id": 4, - "meeting_id": 1, - "entries": [{"user_id": 2, "weight": 1}, {"user_id": 3, "weight": 2}], - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "poll_candidate_list/1", - {"option_id": 4, "meeting_id": 1, "poll_candidate_ids": [1, 2]}, - ) - self.assert_model_exists( - "poll_candidate/1", - {"user_id": 2, "meeting_id": 1, "poll_candidate_list_id": 1, "weight": 1}, - ) - self.assert_model_exists( - "poll_candidate/2", - {"user_id": 3, "meeting_id": 1, "poll_candidate_list_id": 1, "weight": 2}, - ) - self.assert_model_exists("user/2", {"poll_candidate_ids": [1]}) - self.assert_model_exists("user/3", {"poll_candidate_ids": [2]}) - self.assert_model_exists( - "meeting/1", {"poll_candidate_list_ids": [1], "poll_candidate_ids": [1, 2]} - ) - self.assert_model_exists( - "option/4", {"content_object_id": "poll_candidate_list/1"} - ) diff --git a/tests/system/action/poll_candidate_list/test_delete.py b/tests/system/action/poll_candidate_list/test_delete.py deleted file mode 100644 index a8f269f964..0000000000 --- a/tests/system/action/poll_candidate_list/test_delete.py +++ /dev/null @@ -1,45 +0,0 @@ -from tests.system.action.base import BaseActionTestCase - - -class PollCandidateListDeleteTest(BaseActionTestCase): - def test_delete_correct(self) -> None: - self.create_meeting() - self.create_user("test1") - self.create_user("test2") - self.set_models( - { - "poll_candidate_list/2": {"meeting_id": 1}, - "option/1": { - "meeting_id": 1, - "content_object_id": "poll_candidate_list/2", - }, - "poll_candidate/3": { - "meeting_id": 1, - "poll_candidate_list_id": 2, - "user_id": 1, - "weight": 1, - }, - "poll_candidate/4": { - "meeting_id": 1, - "poll_candidate_list_id": 2, - "user_id": 2, - "weight": 2, - }, - "poll_candidate/5": { - "meeting_id": 1, - "poll_candidate_list_id": 2, - "user_id": 3, - "weight": 3, - }, - } - ) - response = self.request("poll_candidate_list.delete", {"id": 2}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("poll_candidate_list/2") - for poll_candidate_id in range(3, 6): - self.assert_model_not_exists(f"poll_candidate/{poll_candidate_id}") - for user_id in range(1, 4): - self.assert_model_exists(f"user/{user_id}", {"poll_candidate_ids": None}) - self.assert_model_exists( - "meeting/1", {"poll_candidate_list_ids": None, "poll_candidate_ids": None} - ) diff --git a/tests/system/action/projector/test_toggle.py b/tests/system/action/projector/test_toggle.py index 4893c26219..f3f8af0c0b 100644 --- a/tests/system/action/projector/test_toggle.py +++ b/tests/system/action/projector/test_toggle.py @@ -16,12 +16,15 @@ def create_poll(self, base: int) -> None: f"poll/{base}": { "meeting_id": 1, "title": "A very important change", - "type": Poll.TYPE_PSEUDOANONYMOUS, - "backend": "fast", - "pollmethod": "YN", - "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_YN, + "visibility": Poll.VISIBILITY_SECRET, + "config_id": f"poll_config_rating_approval/{base}", + "state": Poll.STATE_STARTED, "content_object_id": "motion/1", - } + }, + f"poll_config_rating_approval/{base}": { + "allow_abstain": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, } ) diff --git a/tests/system/action/test_internal_actions.py b/tests/system/action/test_internal_actions.py index 98003afd1f..181d56a40c 100644 --- a/tests/system/action/test_internal_actions.py +++ b/tests/system/action/test_internal_actions.py @@ -223,12 +223,12 @@ def test_internal_execute_stack_internal_action(self) -> None: def test_internal_execute_backend_internal_action(self) -> None: response = self.internal_request( - "option.create", - {"meeting_id": 1, "text": "test"}, + "meeting_user.create", + {"meeting_id": 1, "user_id": 1}, self.internal_auth_password, ) self.assert_status_code(response, 400) self.assertEqual( - response.json.get("message"), "Action option.create does not exist." + response.json.get("message"), "Action meeting_user.create does not exist." ) - self.assert_model_not_exists("option/1") + self.assert_model_not_exists("meeting_user/1") diff --git a/tests/system/action/test_migration_route.py b/tests/system/action/test_migration_route.py index 940b84c4b3..e5633f9672 100644 --- a/tests/system/action/test_migration_route.py +++ b/tests/system/action/test_migration_route.py @@ -3,6 +3,8 @@ from typing import Any from unittest.mock import Mock, patch +import pytest + from openslides_backend.http.views.action_view import ActionView from openslides_backend.migrations.exceptions import MigrationException from openslides_backend.migrations.migration_handler import MigrationState @@ -18,6 +20,7 @@ from .test_internal_actions import BaseInternalPasswordTest, BaseInternalRequestTest +@pytest.mark.skip(reason="Waiting for the migration framework.") class BaseMigrationRouteTest(BaseInternalRequestTest, BaseMigrationTestCase): """ Uses the anonymous client to call the migration route. diff --git a/tests/system/action/user/test_create.py b/tests/system/action/user/test_create.py index 1a3040ef3b..b1379a791e 100644 --- a/tests/system/action/user/test_create.py +++ b/tests/system/action/user/test_create.py @@ -134,6 +134,10 @@ def test_create_some_more_fields(self) -> None: "meeting/114", ], ) + self.assert_history_information( + "meeting_user/1", + structured_information={"group_ids": {"added": [114]}}, + ) def test_create_comment(self) -> None: self.create_meeting() diff --git a/tests/system/action/user/test_delegation_history.py b/tests/system/action/user/test_delegation_history.py index df5ce87bc3..d9d0d822eb 100644 --- a/tests/system/action/user/test_delegation_history.py +++ b/tests/system/action/user/test_delegation_history.py @@ -19,6 +19,7 @@ def setUp(self) -> None: ) self.colin_meeting_user_id = meeting_user_ids.pop(0) self.next_user_id = self.colin_id + 1 + self.next_meeting_user_id = self.colin_meeting_user_id + 1 def setup_delegation(self) -> None: self.set_models( @@ -93,6 +94,16 @@ def assert_alice_redelegated_to(self, who_id: int, prepend: list[str] = []) -> N ], ) + def assert_meeting_user_structured_information(self, user_id: int) -> None: + self.assert_model_exists( + f"meeting_user/{self.next_meeting_user_id}", + {"user_id": user_id, "meeting_id": 1, "group_ids": [3]}, + ) + self.assert_history_information( + f"meeting_user/{self.next_meeting_user_id}", + structured_information={"group_ids": {"added": [3]}}, + ) + def test_update_delegate_vote(self) -> None: self.make_request({"vote_delegated_to_id": self.bob_id - 1}, self.alice_id) self.assert_delegated_to(self.alice_id, self.bob_id) @@ -117,6 +128,7 @@ def test_create_delegate_vote(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(self.next_user_id) def test_create_receive_delegated_vote(self) -> None: self.make_request( @@ -134,6 +146,7 @@ def test_create_receive_delegated_vote(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(self.next_user_id) def test_update_re_delegate_vote(self) -> None: self.setup_delegation() @@ -163,6 +176,7 @@ def test_create_re_delegate_vote_reverse(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(self.next_user_id) def test_update_re_delegate_received_votes(self) -> None: self.setup_delegation() @@ -334,6 +348,7 @@ def test_update_multiple_from_ids(self) -> None: def test_create_multiple_from_ids(self) -> None: eric_id = self.create_user("eric", [3]) fredric_id = self.create_user("fredric", [3]) + self.next_meeting_user_id += 2 self.make_request( { "vote_delegations_from_ids": [ @@ -372,6 +387,7 @@ def test_create_multiple_from_ids(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(self.next_user_id + 2) for id_ in [self.alice_id, self.bob_id, self.colin_id, eric_id, fredric_id]: self.assert_history_information( f"user/{id_}", @@ -405,6 +421,7 @@ def test_update_create_meeting_user_receiving_delegation(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(debra_id) def test_update_create_meeting_user_with_delegation(self) -> None: debra_id = self.create_user("debra") @@ -432,3 +449,4 @@ def test_update_create_meeting_user_with_delegation(self) -> None: "meeting/1", ], ) + self.assert_meeting_user_structured_information(debra_id) diff --git a/tests/system/action/user/test_delete.py b/tests/system/action/user/test_delete.py index e9c5944405..de43fc7078 100644 --- a/tests/system/action/user/test_delete.py +++ b/tests/system/action/user/test_delete.py @@ -1,6 +1,7 @@ from datetime import datetime from zoneinfo import ZoneInfo +from openslides_backend.models.models import Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permissions from openslides_backend.shared.util import ONE_ORGANIZATION_FQID @@ -190,60 +191,39 @@ def test_delete_with_submitter(self) -> None: ) self.assert_model_exists("motion/50", {"submitter_ids": [34]}) - def test_delete_with_poll_candidate(self) -> None: + def test_delete_poll_option_meeting_user(self) -> None: self.create_meeting() + user_id = self.create_user_for_meeting(1) self.set_models( { - "user/111": { - "username": "username_srtgb123", - "poll_candidate_ids": [34], - }, - "poll_candidate/34": { - "user_id": 111, - "poll_candidate_list_id": 1, - "weight": 1, + "poll/1": { + "title": "Poll 1", "meeting_id": 1, + "content_object_id": "assignment/1", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/1", + "state": Poll.STATE_FINISHED, }, - "poll_candidate_list/1": {"meeting_id": 1}, - "option/1": { + "list_of_speakers/1": { "meeting_id": 1, - "content_object_id": "poll_candidate_list/1", + "content_object_id": "assignment/1", }, - } - ) - response = self.request("user.delete", {"id": 111}) - - self.assert_status_code(response, 200) - self.assert_model_not_exists("user/111") - self.assert_model_exists("poll_candidate/34", {"user_id": None}) - - def test_delete_poll_option_user(self) -> None: - self.create_meeting() - self.create_meeting(4) - self.create_topic(1, 1) - bob_id = self.create_user("bob", [1]) - self.set_models( - { - "poll/1": { - "type": "named", - "pollmethod": "Y", - "backend": "long", - "state": "finished", + "assignment/1": { + "id": 1, + "title": "Duckburg town council", "meeting_id": 1, - "content_object_id": "topic/1", - "title": "Poll 1", - "onehundred_percent_base": "YNA", }, - "option/1": { - "meeting_id": 1, - "poll_id": 1, - "content_object_id": f"user/{bob_id}", + "poll_option/1": {"poll_id": 1, "meeting_user_id": 1}, + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, }, - "option/2": {"meeting_id": 1, "poll_id": 1}, } ) - response = self.request("user.delete", {"id": bob_id}) + response = self.request("user.delete", {"id": user_id}) self.assert_status_code(response, 200) + self.assert_model_not_exists(f"user/{user_id}") + self.assert_model_not_exists("meeting_user/1") + self.assert_model_exists("poll_option/1", {"meeting_user_id": None}) def test_delete_with_group_ids_set_null(self) -> None: self.create_meeting() diff --git a/tests/system/action/user/test_merge_together.py b/tests/system/action/user/test_merge_together.py index 15751cb962..b3e24fb064 100644 --- a/tests/system/action/user/test_merge_together.py +++ b/tests/system/action/user/test_merge_together.py @@ -5,20 +5,23 @@ from typing import Any, Literal, cast from zoneinfo import ZoneInfo +from psycopg.types.json import Jsonb + from openslides_backend.action.actions.speaker.speech_state import SpeechState from openslides_backend.action.relations.relation_manager import RelationManager from openslides_backend.action.util.actions_map import actions_map +from openslides_backend.models.models import Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.shared.patterns import ( CollectionField, fqid_from_collection_and_id, ) from openslides_backend.shared.util import ONE_ORGANIZATION_ID -from tests.system.action.poll.test_vote import BaseVoteTestCase +from tests.system.action.base import BaseActionTestCase from tests.util import Response -class UserMergeTogether(BaseVoteTestCase): +class UserMergeTogether(BaseActionTestCase): """committee/63 is created but remains unused in all of the tests as 60 is used for meeting/1 and 4""" def setUp(self) -> None: @@ -486,44 +489,110 @@ def setup_complex_user_fields(self) -> None: "original_model_id": "user/2", "model_id": "user/2", "position_id": 1, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to groups 1, 2", + ], }, "history_entry/2": { + "original_model_id": "meeting_user/12", + "model_id": "meeting_user/12", + "position_id": 1, + "structured_information": Jsonb({"group_ids": {"added": [1, 2]}}), + }, + "history_entry/3": { "original_model_id": "user/3", "model_id": "user/3", "position_id": 1, "entries": ["User created"], }, - "history_entry/3": { + "history_entry/4": { "original_model_id": "user/4", "model_id": "user/4", "position_id": 1, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to groups 2, 4, 9", + ], + }, + "history_entry/5": { + "original_model_id": "meeting_user/14", + "model_id": "meeting_user/14", + "position_id": 1, + "structured_information": Jsonb({"group_ids": {"added": [2]}}), + }, + "history_entry/6": { + "original_model_id": "meeting_user/44", + "model_id": "meeting_user/44", + "position_id": 1, + "structured_information": Jsonb({"group_ids": {"added": [4]}}), + }, + "history_entry/7": { + "original_model_id": "meeting_user/74", + "model_id": "meeting_user/74", + "position_id": 1, + "structured_information": Jsonb({"group_ids": {"added": [9]}}), }, "history_position/2": { "timestamp": datetime.fromtimestamp(200000, ZoneInfo("UTC")), "original_user_id": 4, "user_id": 4, }, - "history_entry/4": { + "history_entry/8": { "original_model_id": "user/2", "model_id": "user/2", "position_id": 2, - "entries": ["User added to meetings"], + "entries": [ + "User added to meetings", + "User added to group 5", + ], }, - "history_entry/5": { + "history_entry/9": { + "original_model_id": "meeting_user/42", + "model_id": "meeting_user/42", + "position_id": 2, + "structured_information": Jsonb({"group_ids": {"added": [5]}}), + }, + "history_entry/10": { "original_model_id": "user/3", "model_id": "user/3", "position_id": 2, - "entries": ["User added to meetings"], + "entries": [ + "User added to meetings", + "User added to groups 4, 5, 8", + ], + }, + "history_entry/11": { + "original_model_id": "meeting_user/43", + "model_id": "meeting_user/43", + "position_id": 2, + "structured_information": Jsonb({"group_ids": {"added": [4, 5]}}), }, - "history_entry/6": { + "history_entry/12": { + "original_model_id": "meeting_user/73", + "model_id": "meeting_user/73", + "position_id": 2, + "structured_information": Jsonb({"group_ids": {"added": [8]}}), + }, + "history_entry/13": { "original_model_id": "user/5", "model_id": "user/5", "position_id": 2, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to group 10", + ], + }, + "history_entry/14": { + "original_model_id": "meeting_user/105", + "model_id": "meeting_user/105", + "position_id": 2, + "structured_information": Jsonb({"group_ids": {"added": [10]}}), }, - "history_entry/7": { + "history_entry/15": { "original_model_id": "user/6", "model_id": "user/6", "position_id": 2, @@ -550,6 +619,7 @@ def test_merge_with_user_fields(self) -> None: "organization_id": 1, "default_password": "user2", "meeting_user_ids": [12, 42, 106, 107], + "history_entry_ids": [1, 3, 4, 8, 10, 13, 15, 16], "password": password, "pronoun": "he", "first_name": "Nick", @@ -582,6 +652,7 @@ def test_merge_with_user_fields(self) -> None: "about_me": "I am an enthusiastic explorer", "comment": "Nicks everything", "number": "NOMNOM", + "history_entry_ids": [2, 5], }, ) self.assert_model_exists( @@ -592,6 +663,7 @@ def test_merge_with_user_fields(self) -> None: "number": "num?", "vote_weight": Decimal("2"), "comment": "Comment 1", + "history_entry_ids": [6, 9, 11], }, ) self.assert_model_exists( @@ -601,6 +673,7 @@ def test_merge_with_user_fields(self) -> None: "meeting_id": 7, "about_me": "I have a long beard", "vote_weight": Decimal("1.234567"), + "history_entry_ids": [7, 12], }, ) self.assert_model_exists( @@ -609,6 +682,7 @@ def test_merge_with_user_fields(self) -> None: "user_id": 2, "meeting_id": 10, "comment": "This is a comment", + "history_entry_ids": [14], }, ) @@ -645,51 +719,117 @@ def test_merge_with_user_fields(self) -> None: "timestamp": datetime.fromtimestamp(100000, ZoneInfo("UTC")), "original_user_id": 1, "user_id": 1, - "entry_ids": [1, 2, 3], + "entry_ids": [1, 2, 3, 4, 5, 6, 7], }, "history_entry/1": { "original_model_id": "user/2", "model_id": "user/2", "position_id": 1, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to groups 1, 2", + ], }, "history_entry/2": { + "original_model_id": "meeting_user/12", + "model_id": "meeting_user/12", + "position_id": 1, + "structured_information": {"group_ids": {"added": [1, 2]}}, + }, + "history_entry/3": { "original_model_id": "user/3", "model_id": "user/2", "position_id": 1, "entries": ["User created"], }, - "history_entry/3": { + "history_entry/4": { "original_model_id": "user/4", "model_id": "user/2", "position_id": 1, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to groups 2, 4, 9", + ], + }, + "history_entry/5": { + "original_model_id": "meeting_user/14", + "model_id": "meeting_user/12", + "position_id": 1, + "structured_information": {"group_ids": {"added": [2]}}, + }, + "history_entry/6": { + "original_model_id": "meeting_user/44", + "model_id": "meeting_user/42", + "position_id": 1, + "structured_information": {"group_ids": {"added": [4]}}, + }, + "history_entry/7": { + "original_model_id": "meeting_user/74", + "model_id": "meeting_user/106", + "position_id": 1, + "structured_information": {"group_ids": {"added": [9]}}, }, "history_position/2": { "timestamp": datetime.fromtimestamp(200000, ZoneInfo("UTC")), "original_user_id": 4, "user_id": 2, - "entry_ids": [4, 5, 6, 7], + "entry_ids": [8, 9, 10, 11, 12, 13, 14, 15], }, - "history_entry/4": { + "history_entry/8": { "original_model_id": "user/2", "model_id": "user/2", "position_id": 2, - "entries": ["User added to meetings"], + "entries": [ + "User added to meetings", + "User added to group 5", + ], }, - "history_entry/5": { + "history_entry/9": { + "original_model_id": "meeting_user/42", + "model_id": "meeting_user/42", + "position_id": 2, + "structured_information": {"group_ids": {"added": [5]}}, + }, + "history_entry/10": { "original_model_id": "user/3", "model_id": "user/2", "position_id": 2, - "entries": ["User added to meetings"], + "entries": [ + "User added to meetings", + "User added to groups 4, 5, 8", + ], }, - "history_entry/6": { + "history_entry/11": { + "original_model_id": "meeting_user/43", + "model_id": "meeting_user/42", + "position_id": 2, + "structured_information": {"group_ids": {"added": [4, 5]}}, + }, + "history_entry/12": { + "original_model_id": "meeting_user/73", + "model_id": "meeting_user/106", + "position_id": 2, + "structured_information": {"group_ids": {"added": [8]}}, + }, + "history_entry/13": { "original_model_id": "user/5", "model_id": "user/2", "position_id": 2, - "entries": ["User created", "User added to meetings"], + "entries": [ + "User created", + "User added to meetings", + "User added to group 10", + ], }, - "history_entry/7": { + "history_entry/14": { + "original_model_id": "meeting_user/105", + "model_id": "meeting_user/107", + "position_id": 2, + "structured_information": {"group_ids": {"added": [10]}}, + }, + "history_entry/15": { "original_model_id": "user/6", "model_id": "user/2", "position_id": 2, @@ -850,126 +990,162 @@ def set_up_polls_for_merge(self) -> None: "meeting_id": 4, "meeting_user_id": 43, }, - } - ) - self.request_multi( - "poll.create", - [ - { + "poll/1": { "title": "Assignment poll 1", "content_object_id": "assignment/1", - "type": "named", - "pollmethod": "Y", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/1", "meeting_id": 1, - "options": [ - {"content_object_id": "user/2"}, - {"content_object_id": "user/5"}, - ], - "global_no": True, - "min_votes_amount": 1, - "max_votes_amount": 2, - "max_votes_per_option": 1, - "backend": "long", - "entitled_group_ids": [1, 2, 3], + "state": Poll.STATE_FINISHED, }, - { + "poll_config_approval/1": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_option/1": {"poll_id": 1, "meeting_user_id": 12}, + "poll_option/2": {"poll_id": 1, "meeting_user_id": 15}, + "poll/2": { "title": "Assignment poll 2", "content_object_id": "assignment/1", - "type": "named", - "pollmethod": "YN", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_selection/1", "meeting_id": 1, - "options": [ - {"poll_candidate_user_ids": [5, 4]}, - ], - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "backend": "fast", - "entitled_group_ids": [1, 2, 3], + "state": Poll.STATE_FINISHED, }, - { + "poll_config_selection/1": { + "min_options_amount": 1, + "max_options_amount": 1, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, + "poll_option/3": {"poll_id": 2, "meeting_user_id": 15}, + "poll_option/4": {"poll_id": 2, "meeting_user_id": 14}, + "poll/3": { "title": "Assignment poll 3", "content_object_id": "assignment/1", - "type": "named", - "pollmethod": "YN", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/2", "meeting_id": 1, - "options": [ - {"poll_candidate_user_ids": [2, 5]}, - ], - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "backend": "fast", - "entitled_group_ids": [1, 2, 3], + "state": Poll.STATE_FINISHED, }, - { + "poll_config_approval/2": { + "allow_abstain": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, + "poll_option/5": {"poll_id": 3, "meeting_user_id": 12}, + "poll_option/6": {"poll_id": 3, "meeting_user_id": 15}, + "poll/4": { "title": "Assignment poll 4", "content_object_id": "assignment/1", - "type": "pseudoanonymous", - "pollmethod": "Y", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_approval/3", "meeting_id": 1, - "options": [ - {"content_object_id": "user/4"}, - {"content_object_id": "user/5"}, - ], - "min_votes_amount": 1, - "max_votes_amount": 2, - "max_votes_per_option": 1, - "backend": "long", - "entitled_group_ids": [1, 2, 3], + "state": Poll.STATE_FINISHED, }, - { + "poll_config_approval/3": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_option/7": {"poll_id": 4, "meeting_user_id": 14}, + "poll_option/8": {"poll_id": 4, "meeting_user_id": 15}, + "poll/5": { "title": "Motion poll", "content_object_id": "motion/4", - "type": "named", - "pollmethod": "YNA", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/4", "meeting_id": 4, - "options": [ - {"content_object_id": "motion/4"}, - ], - "min_votes_amount": 1, - "max_votes_amount": 1, - "max_votes_per_option": 1, - "backend": "fast", - "entitled_group_ids": [4, 5, 6], + "state": Poll.STATE_FINISHED, }, - { + "poll_config_approval/4": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll/6": { "title": "Topic poll", "content_object_id": "topic/7", - "type": "pseudoanonymous", - "pollmethod": "Y", + "visibility": Poll.VISIBILITY_SECRET, + "config_id": "poll_config_rating_score/1", "meeting_id": 7, - "options": [ - {"text": "Option 1"}, - {"text": "Option 2"}, - {"text": "Option 3"}, - ], - "min_votes_amount": 1, - "max_votes_amount": 3, - "max_votes_per_option": 1, - "backend": "fast", + "state": Poll.STATE_FINISHED, "entitled_group_ids": [7, 8, 9], }, - ], + "poll_config_rating_score/1": { + "min_options_amount": 1, + "max_options_amount": 3, + "max_votes_per_option": 1, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, + "poll_option/9": {"poll_id": 6, "text": "Option 1"}, + "poll_option/10": {"poll_id": 6, "text": "Option 2"}, + "poll_option/11": {"poll_id": 6, "text": "Option 3"}, + **{f"group/{id_}": {"poll_ids": [1, 2, 3, 4]} for id_ in range(1, 4)}, + **{f"group/{id_}": {"poll_ids": [5]} for id_ in range(4, 7)}, + **{f"group/{id_}": {"poll_ids": [6]} for id_ in range(7, 10)}, + } ) def create_polls_with_correct_votes(self) -> None: self.set_up_polls_for_merge() - self.request_multi("poll.start", [{"id": i} for i in [3, 4]]) - self.login(4) - self.request("poll.vote", {"id": 1, "value": "N"}, stop_poll_after_vote=False) # type: ignore - self.request( - "poll.vote", - {"id": 1, "value": "N", "user_id": 5}, - start_poll_before_vote=False, - ) - self.login(2) - self.request("poll.vote", {"id": 2, "value": {"4": "Y"}}) - self.login(3) - self.request("poll.vote", {"id": 5, "value": {"11": "A"}}) - self.request("poll.vote", {"id": 6, "value": {"13": 1, "14": 1, "15": 0}}) - self.login(1) - self.request_multi("poll.stop", [{"id": i} for i in [3, 4]]) + self.set_models( + { + "poll_ballot/11": { + "value": "no", + "poll_id": 1, + "poll_ballot_user_id": 1, + }, + "poll_ballot_user/1": { + "poll_id": 1, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 14, + }, + "poll_ballot/12": { + "value": "no", + "poll_id": 1, + "poll_ballot_user_id": 2, + }, + "poll_ballot_user/2": { + "poll_id": 1, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 15, + }, + "poll_ballot/13": { + "value": "4", + "poll_id": 2, + "poll_ballot_user_id": 3, + }, + "poll_ballot_user/3": { + "poll_id": 2, + "acting_meeting_user_id": 12, + "represented_meeting_user_id": 12, + }, + "poll_ballot/14": { + "value": "abstain", + "poll_id": 5, + "poll_ballot_user_id": 4, + }, + "poll_ballot_user/4": { + "poll_id": 5, + "acting_meeting_user_id": 43, + "represented_meeting_user_id": 43, + }, + "group/7": { + "meeting_user_ids": [75], + }, + "meeting_user/75": { + "user_id": 5, + "meeting_id": 7, + "vote_weight": Decimal("1"), + }, + "poll_ballot/15": {"value": "9", "poll_id": 6}, + "poll_ballot_user/5": { + "poll_id": 6, + "acting_meeting_user_id": 75, + "represented_meeting_user_id": 75, + }, + "poll_ballot/16": {"value": "10", "poll_id": 6}, + "poll_ballot_user/6": { + "poll_id": 6, + "acting_meeting_user_id": 73, + "represented_meeting_user_id": 73, + }, + } + ) def assert_merge_with_polls_correct( self, password: str, add_to_creatable_ids: int = 0 @@ -985,125 +1161,109 @@ def assert_merge_with_polls_correct( "default_password": "user2", "meeting_user_ids": [12, 42, 106 + add_to_creatable_ids], "password": password, - "poll_candidate_ids": [2, 3], - "option_ids": [1, 8], - "poll_voted_ids": [1, 2, 5, 6], - "vote_ids": [1, 3, 4], - "delegated_vote_ids": [1, 2, 3, 4], }, ) self.assert_model_exists("committee/60", {"user_ids": [2, 5]}) - self.assert_model_exists("committee/66", {"user_ids": [2]}) + self.assert_model_exists("committee/66", {"user_ids": [2, 5]}) for id_ in range(3, 5): self.assert_model_not_exists(f"user/{id_}") for id_ in [43, 73, 14, 44, 74, *range(106, 106 + add_to_creatable_ids)]: self.assert_model_not_exists(f"meeting_user/{id_}") - for meeting_id, id_ in {1: 12, 7: 106 + add_to_creatable_ids}.items(): - self.assert_model_exists( - f"meeting_user/{id_}", {"user_id": 2, "meeting_id": meeting_id} - ) + self.assert_model_exists( + "meeting_user/12", + { + "user_id": 2, + "meeting_id": 1, + "poll_option_ids": [1, 4, 5, 7], + "acting_ballot_ids": [1, 2, 3], + "represented_ballot_ids": [1, 3], + }, + ) self.assert_model_exists( "meeting_user/42", - {"user_id": 2, "meeting_id": 4, "motion_submitter_ids": [1]}, + { + "user_id": 2, + "meeting_id": 4, + "motion_submitter_ids": [1], + "acting_ballot_ids": [4], + "represented_ballot_ids": [4], + }, + ) + self.assert_model_exists( + f"meeting_user/{106 + add_to_creatable_ids}", + {"user_id": 2, "meeting_id": 7}, ) self.assert_model_not_exists("motion_submitter/2") self.assert_model_exists( "motion_submitter/1", {"motion_id": 4, "meeting_user_id": 42, "meeting_id": 4, "weight": 1}, ) - self.assert_model_exists("poll_candidate/2", {"user_id": 2}) - self.assert_model_exists("poll_candidate/3", {"user_id": 2}) - self.assert_model_exists("vote/2", {"user_id": 5, "delegated_user_id": 2}) - for id_ in [1, 3, 4]: + for poll_option_id in [1, 4, 5, 7]: self.assert_model_exists( - f"vote/{id_}", {"user_id": 2, "delegated_user_id": 2} + f"poll_option/{poll_option_id}", {"meeting_user_id": 12} ) - for id_ in [5, 6]: # pseudoanonymous options + + self.assert_model_exists( + "poll_ballot_user/2", + {"acting_meeting_user_id": 12, "represented_meeting_user_id": 15}, + ) + for id_ in [1, 3]: self.assert_model_exists( - f"vote/{id_}", - {"option_id": id_ + 8, "user_id": None, "delegated_user_id": None}, + f"poll_ballot_user/{id_}", + {"acting_meeting_user_id": 12, "represented_meeting_user_id": 12}, ) - self.assert_model_exists("option/1", {"content_object_id": "user/2"}) - self.assert_model_exists("option/8", {"content_object_id": "user/2"}) - - def build_expected_user_dates( - voted_present_user_delegated_merged: list[ - tuple[bool, bool, int, int | None, int | None, int | None] - ], - ) -> list[dict[str, Any]]: - return [ + self.assert_model_exists( + "poll_ballot_user/4", + {"acting_meeting_user_id": 42, "represented_meeting_user_id": 42}, + ) + for id_ in [5, 6]: # anonymous ballots + self.assert_model_exists( + f"poll_ballot/{10 + id_}", { - "voted": date[0], - "present": date[1], - "user_id": date[2], - "vote_delegated_to_user_id": date[3], - **({"user_merged_into_id": date[4]} if date[4] else {}), - **({"delegation_user_merged_into_id": date[5]} if date[5] else {}), - } - for date in voted_present_user_delegated_merged - ] - + "poll_id": 6, + "value": str(id_ + 4), + "poll_ballot_user_id": None, + }, + ) self.assert_model_exists( - "poll/1", + "poll_ballot_user/5", { - "voted_ids": [2, 5], - "entitled_users_at_stop": build_expected_user_dates( - [ - (False, True, 2, None, None, None), - (True, True, 4, None, 2, None), - (True, False, 5, 4, None, 2), - ] - ), + "poll_id": 6, + "poll_ballot_id": None, + "acting_meeting_user_id": 75, + "represented_meeting_user_id": 75, }, ) self.assert_model_exists( - "poll/2", + "poll_ballot_user/6", { - "voted_ids": [2], - "entitled_users_at_stop": build_expected_user_dates( - [ - (True, True, 2, None, None, None), - (False, True, 4, None, 2, None), - (False, False, 5, 4, None, 2), - ] - ), + "poll_id": 6, + "poll_ballot_id": None, + "acting_meeting_user_id": 106 + add_to_creatable_ids, + "represented_meeting_user_id": 106 + add_to_creatable_ids, }, ) + + self.assert_model_exists( + "poll/1", + {"ballot_ids": [11, 12], "ballot_user_ids": [1, 2]}, + ) + self.assert_model_exists( + "poll/2", + {"ballot_ids": [13], "ballot_user_ids": [3]}, + ) for id_ in [3, 4]: self.assert_model_exists( f"poll/{id_}", - { - "voted_ids": None, - "entitled_users_at_stop": build_expected_user_dates( - [ - (False, True, 2, None, None, None), - (False, True, 4, None, 2, None), - (False, False, 5, 4, None, 2), - ] - ), - }, + {"ballot_ids": None, "ballot_user_ids": None}, ) self.assert_model_exists( "poll/5", - { - "voted_ids": [2], - "entitled_users_at_stop": build_expected_user_dates( - [ - (False, False, 2, None, None, None), - (True, True, 3, None, 2, None), - (False, True, 4, None, 2, None), - ] - ), - }, + {"ballot_ids": [14], "ballot_user_ids": [4]}, ) self.assert_model_exists( "poll/6", - { - "voted_ids": [2], - "entitled_users_at_stop": build_expected_user_dates( - [(True, True, 3, None, 2, None), (False, True, 4, None, 2, None)] - ), - }, + {"ballot_ids": [15, 16], "ballot_user_ids": [5, 6]}, ) def test_merge_with_polls_correct(self) -> None: @@ -1124,21 +1284,71 @@ def test_merge_with_polls_and_subsequent_merges(self) -> None: def test_merge_with_polls_all_errors(self) -> None: self.set_up_polls_for_merge() - self.request_multi("poll.start", [{"id": i} for i in [3, 4]]) - self.login(4) - self.request("poll.vote", {"id": 1, "value": "N"}, stop_poll_after_vote=False) - self.request( - "poll.vote", - {"id": 1, "value": "N", "user_id": 5}, - start_poll_before_vote=False, - ) - self.login(2) - self.request("poll.vote", {"id": 2, "value": {"4": "Y"}}) - self.login(3) - self.request("poll.vote", {"id": 5, "value": {"11": "A"}}) - self.request("poll.vote", {"id": 6, "value": {"13": 1, "14": 1, "15": 0}}) - self.login(1) - self.request("poll.stop", {"id": 3}) + self.set_models( + { + "poll/4": {"state": Poll.STATE_STARTED}, + "poll_ballot/11": { + "value": "no", + "poll_id": 1, + "poll_ballot_user_id": 1, + }, + "poll_ballot_user/1": { + "poll_id": 1, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 14, + }, + "poll_ballot/12": { + "value": "no", + "poll_id": 1, + "poll_ballot_user_id": 2, + }, + "poll_ballot_user/2": { + "poll_id": 1, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 15, + }, + "poll_ballot/13": { + "value": "4", + "poll_id": 2, + "poll_ballot_user_id": 3, + }, + "poll_ballot_user/3": { + "poll_id": 2, + "acting_meeting_user_id": 12, + "represented_meeting_user_id": 12, + }, + "poll_ballot/14": { + "value": "abstain", + "poll_id": 5, + "poll_ballot_user_id": 4, + }, + "poll_ballot_user/4": { + "poll_id": 5, + "acting_meeting_user_id": 43, + "represented_meeting_user_id": 43, + }, + "group/7": { + "meeting_user_ids": [75], + }, + "meeting_user/75": { + "user_id": 5, + "meeting_id": 7, + "vote_weight": Decimal("1"), + }, + "poll_ballot/15": {"value": "9", "poll_id": 6}, + "poll_ballot_user/5": { + "poll_id": 6, + "acting_meeting_user_id": 75, + "represented_meeting_user_id": 75, + }, + "poll_ballot/16": {"value": "10", "poll_id": 6}, + "poll_ballot_user/6": { + "poll_id": 6, + "acting_meeting_user_id": 73, + "represented_meeting_user_id": 73, + }, + } + ) response = self.request("user.merge_together", {"id": 2, "user_ids": [3, 4, 5]}) self.assert_status_code(response, 400) assert ( @@ -1148,9 +1358,8 @@ def test_merge_with_polls_all_errors(self) -> None: "some of the users are entitled to vote in currently running polls in meeting(s) 1", "some of the selected users have different delegations roles in meeting(s) 1", "some of the selected users are delegating votes to each other in meeting(s) 1", - "among the selected users multiple voted in poll(s) 1", - "multiple of the selected users are among the options in poll(s) 1, 4", - "multiple of the selected users are in the same candidate list in poll(s) 2, 3", + "among the selected users multiple voted in poll(s) 1, 6", + "multiple of the selected users are among the options in poll(s) 1, 2, 3, 4", ] ) in response.json["message"] diff --git a/tests/system/action/user/test_set_present.py b/tests/system/action/user/test_set_present.py index 17b759b660..024928200d 100644 --- a/tests/system/action/user/test_set_present.py +++ b/tests/system/action/user/test_set_present.py @@ -8,35 +8,81 @@ def setUp(self) -> None: super().setUp() self.create_meeting() + def base_test_set_present_add_correct(self, with_meeting_user: bool) -> None: + self.set_models({"user/111": {"username": "username_srtgb123"}}) + if with_meeting_user: + self.set_user_groups(111, [3]) + response = self.request( + "user.set_present", {"id": 111, "meeting_id": 1, "present": True} + ) + self.assert_status_code(response, 200) + self.assert_model_exists("user/111", {"is_present_in_meeting_ids": [1]}) + self.assert_model_exists("meeting/1", {"present_user_ids": [111]}) + self.assert_history_information( + "user/111", + ["Set present in meeting {}", "meeting/1"], + ) + if with_meeting_user: + self.assert_history_information( + "meeting_user/1", + structured_information={"is_present": True}, + ) + def test_set_present_add_correct(self) -> None: + self.base_test_set_present_add_correct(False) + + def test_set_present_add_correct_with_meeting_user(self) -> None: + self.base_test_set_present_add_correct(True) + + def base_test_set_present_add_second_correct(self, with_meeting_user: bool) -> None: self.set_models( { + "meeting/1": {"present_user_ids": [111]}, "user/111": {"username": "username_srtgb123"}, } ) + self.create_meeting(4) + if with_meeting_user: + self.set_user_groups(111, [3, 6]) response = self.request( - "user.set_present", {"id": 111, "meeting_id": 1, "present": True} + "user.set_present", {"id": 111, "meeting_id": 4, "present": True} ) self.assert_status_code(response, 200) - model = self.get_model("user/111") - assert model.get("is_present_in_meeting_ids") == [1] - meeting = self.get_model("meeting/1") - assert meeting.get("present_user_ids") == [111] + self.assert_model_exists("user/111", {"is_present_in_meeting_ids": [1, 4]}) + self.assert_model_exists("meeting/1", {"present_user_ids": [111]}) + self.assert_model_exists("meeting/4", {"present_user_ids": [111]}) self.assert_history_information( - "user/111", ["Set present in meeting {}", "meeting/1"] + "user/111", + ["Set present in meeting {}", "meeting/4"], ) + if with_meeting_user: + self.assert_model_exists( + "meeting_user/1", {"user_id": 111, "meeting_id": 1} + ) + self.assert_history_information("meeting_user/1") + self.assert_model_exists( + "meeting_user/2", {"user_id": 111, "meeting_id": 4} + ) + self.assert_history_information( + "meeting_user/2", + structured_information={"is_present": True}, + ) - def test_set_present_del_correct(self) -> None: + def test_set_present_add_second_correct(self) -> None: + self.base_test_set_present_add_second_correct(False) + + def test_set_present_add_second_correct_with_meeting_users(self) -> None: + self.base_test_set_present_add_second_correct(True) + + def base_test_set_present_del_correct(self, with_meeting_user: bool) -> None: self.set_models( { - "meeting/1": { - "present_user_ids": [111], - }, - "user/111": { - "username": "username_srtgb123", - }, + "meeting/1": {"present_user_ids": [111]}, + "user/111": {"username": "username_srtgb123"}, } ) + if with_meeting_user: + self.set_user_groups(111, [3]) response = self.request( "user.set_present", {"id": 111, "meeting_id": 1, "present": False} ) @@ -44,8 +90,20 @@ def test_set_present_del_correct(self) -> None: self.assert_model_exists("user/111", {"is_present_in_meeting_ids": None}) self.assert_model_exists("meeting/1", {"present_user_ids": None}) self.assert_history_information( - "user/111", ["Set not present in meeting {}", "meeting/1"] + "user/111", + ["Set not present in meeting {}", "meeting/1"], ) + if with_meeting_user: + self.assert_history_information( + "meeting_user/1", + structured_information={"is_present": False}, + ) + + def test_set_present_del_correct(self) -> None: + self.base_test_set_present_del_correct(False) + + def test_set_present_del_correct_with_meeting_user(self) -> None: + self.base_test_set_present_del_correct(True) def test_set_present_null_action(self) -> None: self.set_models( diff --git a/tests/system/action/user/test_update.py b/tests/system/action/user/test_update.py index e545797b6d..8644285743 100644 --- a/tests/system/action/user/test_update.py +++ b/tests/system/action/user/test_update.py @@ -290,9 +290,13 @@ def test_update_with_meeting_user_fields(self) -> None: "Committee management changed", ], ) + self.assert_history_information( + "meeting_user/224", structured_information={"group_ids": {"added": [1]}} + ) self.assert_history_information( "user/23", ["Vote delegated to {} in meeting {}", "user/22", "meeting/1"] ) + self.assert_history_information("meeting_user/223") def test_update_set_and_reset_vote_forwarded(self) -> None: self.create_meeting() @@ -491,6 +495,10 @@ def test_committee_manager_without_committee_ids(self) -> None: "Committee management changed", ], ) + self.assert_history_information( + "meeting_user/1111", + structured_information={"group_ids": {"removed": [600]}}, + ) def test_committee_manager_remove_committee_ids(self) -> None: self.set_models( @@ -2681,6 +2689,11 @@ def test_update_history_add_group(self) -> None: f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/3", "meeting/1"], ) + self.assert_history_information( + "meeting_user/1", + structured_information={"group_ids": {"added": [3]}}, + ) + self.assert_history_information("meeting_user/2") def test_update_history_add_group_to_default_group(self) -> None: self.create_meeting() @@ -2701,6 +2714,16 @@ def test_update_history_add_group_to_default_group(self) -> None: f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/2", "meeting/1"], ) + self.assert_history_information( + "meeting_user/1", + structured_information={ + "group_ids": { + "added": [2], + "removed": [1], + }, + }, + ) + self.assert_history_information("meeting_user/2") def test_update_history_add_multiple_groups(self) -> None: self.create_meeting() @@ -2721,6 +2744,16 @@ def test_update_history_add_multiple_groups(self) -> None: f"user/{user_id}", ["Participant added to multiple groups in meeting {}", "meeting/1"], ) + self.assert_history_information( + "meeting_user/1", + structured_information={ + "group_ids": { + "added": [2, 3], + "removed": [1], + }, + }, + ) + self.assert_history_information("meeting_user/2") def test_update_history_add_multiple_groups_with_default_group(self) -> None: self.create_meeting() @@ -2740,6 +2773,10 @@ def test_update_history_add_multiple_groups_with_default_group(self) -> None: f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/2", "meeting/1"], ) + self.assert_history_information( + "meeting_user/1", + structured_information={"group_ids": {"added": [2]}}, + ) def test_update_history_remove_group(self) -> None: self.create_meeting() @@ -2764,6 +2801,10 @@ def test_update_history_remove_group(self) -> None: f"user/{user_id}", ["Participant removed from meeting {}", "meeting/1"], ) + self.assert_history_information( + "meeting_user/1", + structured_information={"group_ids": {"removed": [1]}}, + ) def test_update_fields_with_equal_value_no_history(self) -> None: self.create_meeting() @@ -2860,6 +2901,11 @@ def test_update_participant_data_with_existing_meetings(self) -> None: "meeting/4", ], ) + self.assert_history_information("meeting_user/1") + self.assert_history_information( + "meeting_user/2", + structured_information={"group_ids": {"added": [4]}}, + ) def test_update_participant_data_in_multiple_meetings_with_existing_meetings( self, @@ -2909,6 +2955,113 @@ def test_update_participant_data_in_multiple_meetings_with_existing_meetings( "meeting/7", ], ) + self.assert_history_information("meeting_user/1") + self.assert_history_information( + "meeting_user/2", + structured_information={"group_ids": {"added": [4]}}, + ) + self.assert_history_information( + "meeting_user/3", + structured_information={"group_ids": {"added": [7]}}, + ) + + def test_update_participant_data_in_multiple_meetings_log_is_present( + self, + ) -> None: + """ + Checks that history information for the new meeting user contains + 'is_present' when user is present in meeting before the request. + """ + bob_id = self.create_user("bob") + self.create_meeting(meeting_data={"present_user_ids": [bob_id]}) + self.create_meeting(4, meeting_data={"present_user_ids": [bob_id]}) + self.create_meeting(7) + self.set_user_groups(bob_id, [1])[0] + response = self.request_multi( + "user.update", + [ + { + "id": bob_id, + "meeting_id": 4, + "group_ids": [4], + }, + { + "id": bob_id, + "meeting_id": 7, + "group_ids": [7], + }, + ], + ) + self.assert_status_code(response, 200) + self.assert_history_information( + f"user/{bob_id}", + [ + "Participant added to meeting {}.", + "meeting/4", + "Participant added to group {} in meeting {}.", + "group/4", + "meeting/4", + "Participant added to meeting {}.", + "meeting/7", + "Participant added to group {} in meeting {}.", + "group/7", + "meeting/7", + ], + ) + self.assert_history_information("meeting_user/1") + self.assert_history_information( + "meeting_user/2", + structured_information={"group_ids": {"added": [4]}, "is_present": True}, + ) + self.assert_history_information( + "meeting_user/3", + structured_information={"group_ids": {"added": [7]}}, + ) + + def test_update_remove_participant_from_multiple_meetings_log_is_present( + self, + ) -> None: + bob_id = self.create_user("bob") + self.create_meeting(meeting_data={"present_user_ids": [bob_id]}) + self.create_meeting(4, meeting_data={"present_user_ids": [bob_id]}) + self.create_meeting(7) + self.set_user_groups(bob_id, [1, 4, 7]) + response = self.request_multi( + "user.update", + [ + { + "id": bob_id, + "meeting_id": 4, + "group_ids": [], + }, + { + "id": bob_id, + "meeting_id": 7, + "group_ids": [], + }, + ], + ) + self.assert_status_code(response, 200) + self.assert_history_information( + f"user/{bob_id}", + [ + "Set not present in meeting {}", + "meeting/4", + "Participant removed from meeting {}", + "meeting/4", + "Participant removed from meeting {}", + "meeting/7", + ], + ) + self.assert_history_information("meeting_user/1") + self.assert_history_information( + "meeting_user/2", + structured_information={"group_ids": {"removed": [4]}, "is_present": False}, + ) + self.assert_history_information( + "meeting_user/3", + structured_information={"group_ids": {"removed": [7]}}, + ) def test_update_saml_id__can_change_own_password_error(self) -> None: self.create_model( @@ -3074,138 +3227,35 @@ def test_partial_group_removal_with_speaker(self) -> None: def test_update_with_internal_fields(self) -> None: self.create_meeting() - self.create_user("dummy2", [1]) - self.create_user("dummy3", [1]) - self.set_user_groups(1, [1]) - self.create_topic(1, 1) - self.set_models( - { - "user/1": { - "username": "boady", - }, - "user/2": {"username": "john", "delegated_vote_ids": [2]}, - "meeting/1": { - "poll_ids": [1], - "option_ids": [1, 2], - "poll_candidate_list_ids": [1], - "poll_candidate_ids": [1], - "vote_ids": [1, 2], - }, - "poll/1": { - "title": "pull", - "type": "analog", - "pollmethod": "YNA", - "meeting_id": 1, - "option_ids": [1, 2], - "content_object_id": "topic/1", - }, - "option/1": { - "meeting_id": 1, - "vote_ids": [1], - "content_object_id": "user/1", - }, - "option/2": { - "meeting_id": 1, - "vote_ids": [2], - "content_object_id": "poll_candidate_list/1", - }, - "poll_candidate_list/1": { - "meeting_id": 1, - "option_id": 2, - "poll_candidate_ids": [1], - }, - "poll_candidate/1": { - "poll_candidate_list_id": 1, - "meeting_id": 1, - "user_id": 1, - "weight": 3, - }, - "vote/1": { - "meeting_id": 1, - "option_id": 1, - "user_id": 1, - "user_token": "dfjdskjfksdjf", - "weight": Decimal("1.000000"), - "value": "Y", - }, - "vote/2": { - "meeting_id": 1, - "option_id": 2, - "user_id": 1, - "delegated_user_id": 2, - "user_token": "dfjdskjfksdjf", - "weight": Decimal("1.000000"), - "value": "Y", - }, - } - ) + self.create_user_for_meeting(1) response = self.request( "user.update", { - "id": 3, + "id": 2, "is_present_in_meeting_ids": [1], - "option_ids": [1], - "poll_candidate_ids": [1], - "poll_voted_ids": [1], - "vote_ids": [1], - "delegated_vote_ids": [2], }, internal=True, ) self.assert_status_code(response, 200) - expected: dict[str, dict[str, Any]] = { - "user/3": { - "is_present_in_meeting_ids": [1], - "option_ids": [1], - "poll_candidate_ids": [1], - "poll_voted_ids": [1], - "vote_ids": [1], - "delegated_vote_ids": [2], - }, - "meeting/1": { - "present_user_ids": [3], - }, - "poll/1": {"voted_ids": [3]}, - "option/1": {"content_object_id": "user/3"}, - "poll_candidate/1": { - "user_id": 3, - }, - "vote/1": {"user_id": 3}, - "vote/2": {"delegated_user_id": 3}, - } - for fqid, model in expected.items(): - self.assert_model_exists(fqid, model) + self.assert_model_exists("user/2", {"is_present_in_meeting_ids": [1]}) + self.assert_model_exists("meeting/1", {"present_user_ids": [2]}) def test_update_with_internal_fields_error(self) -> None: self.create_meeting() - self.create_user("dummy2", [1]) - self.create_user("dummy3", [1]) + self.create_user_for_meeting(1) response = self.request( "user.update", { - "id": 3, + "id": 2, "is_present_in_meeting_ids": [1], - "option_ids": [1], - "poll_candidate_ids": [1], - "poll_voted_ids": [1], - "vote_ids": [1], - "delegated_vote_ids": [2], }, internal=False, ) self.assert_status_code(response, 400) - message: str = response.json["message"] - assert message.startswith("data must not contain {") - assert message.endswith("} properties") - for field in [ - "'is_present_in_meeting_ids'", - "'option_ids'", - "'poll_candidate_ids'", - "'poll_voted_ids'", - "'vote_ids'", - "'delegated_vote_ids'", - ]: - self.assertIn(field, message) + self.assertEqual( + "data must not contain {'is_present_in_meeting_ids'} properties", + response.json["message"], + ) def test_update_groups_on_last_meeting_admin(self) -> None: self.create_meeting() diff --git a/tests/system/action/vote/__init__.py b/tests/system/action/vote/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/system/action/vote/test_create.py b/tests/system/action/vote/test_create.py deleted file mode 100644 index 8ae1dc7ae3..0000000000 --- a/tests/system/action/vote/test_create.py +++ /dev/null @@ -1,30 +0,0 @@ -from decimal import Decimal - -from tests.system.action.base import BaseActionTestCase - - -class VoteCreateActionTest(BaseActionTestCase): - def test_create(self) -> None: - self.create_meeting(111) - self.set_models({"option/12": {"text": "blabalbal", "meeting_id": 111}}) - response = self.request( - "vote.create", - { - "value": "Y", - "weight": "1.000000", - "option_id": 12, - "user_token": "aaaabbbbccccdddd", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "vote/1", - { - "value": "Y", - "meeting_id": 111, - "weight": Decimal("1.000000"), - "option_id": 12, - "user_token": "aaaabbbbccccdddd", - }, - ) - self.assert_model_exists("option/12", {"vote_ids": [1]}) diff --git a/tests/system/action/vote/test_delete.py b/tests/system/action/vote/test_delete.py deleted file mode 100644 index 6ed3f99ba4..0000000000 --- a/tests/system/action/vote/test_delete.py +++ /dev/null @@ -1,31 +0,0 @@ -from decimal import Decimal - -from tests.system.action.base import BaseActionTestCase - - -class VoteDeleteTest(BaseActionTestCase): - def setUp(self) -> None: - super().setUp() - self.create_meeting() - self.set_models( - { - "option/12": {"text": "blabalbal", "meeting_id": 1}, - "vote/111": { - "meeting_id": 1, - "option_id": 12, - "user_token": "aaaabbbbccccdddd", - "weight": Decimal("1.000000"), - "value": "Y", - }, - } - ) - - def test_delete_correct(self) -> None: - response = self.request("vote.delete", {"id": 111}) - self.assert_status_code(response, 200) - self.assert_model_not_exists("vote/111") - - def test_delete_wrong_id(self) -> None: - response = self.request("vote.delete", {"id": 112}) - self.assert_status_code(response, 400) - self.assert_model_exists("vote/111") diff --git a/tests/system/action/vote/test_update.py b/tests/system/action/vote/test_update.py deleted file mode 100644 index 5f757c2cb4..0000000000 --- a/tests/system/action/vote/test_update.py +++ /dev/null @@ -1,26 +0,0 @@ -from decimal import Decimal - -from tests.system.action.base import BaseActionTestCase - - -class VoteUpdateActionTest(BaseActionTestCase): - def test_update(self) -> None: - self.create_meeting(111) - self.set_models( - { - "option/12": {"text": "blabalbal", "meeting_id": 111}, - "vote/1": { - "value": "Y", - "meeting_id": 111, - "weight": "1.000000", - "option_id": 12, - "user_token": "aaaabbbbccccdddd", - }, - } - ) - - response = self.request("vote.update", {"id": 1, "weight": "1.500000"}) - self.assert_status_code(response, 200) - self.assert_model_exists( - "vote/1", {"value": "Y", "meeting_id": 111, "weight": Decimal("1.500000")} - ) diff --git a/tests/system/base.py b/tests/system/base.py index 68a61baeb4..149271cd6a 100644 --- a/tests/system/base.py +++ b/tests/system/base.py @@ -51,8 +51,6 @@ ) from tests.util import AuthData, Client, Response -from .util import TestVoteService - DEFAULT_PASSWORD = "password" ADMIN_USERNAME = "admin" @@ -62,7 +60,6 @@ class BaseSystemTestCase(TestCase): app: OpenSlidesBackendWSGIApplication auth: AuthenticationService - vote_service: TestVoteService media: Any # Any is needed because it is mocked and has magic methods client: Client anon_client: Client @@ -84,7 +81,6 @@ def setUp(self) -> None: self.env = cast(Environment, self.app.env) self.auth = self.services.authentication() self.media = self.services.media() - self.vote_service = cast(TestVoteService, self.services.vote()) self.set_thread_watch_timeout(-1) self.user_id = 1 @@ -103,7 +99,7 @@ def setUp(self) -> None: }, } ) - self.client = self.create_client(self.update_vote_service_auth_data) + self.client = self.create_client() self.client.auth = self.auth # type: ignore if self.init_with_login: if self.auth_data: @@ -192,11 +188,6 @@ def login(self, user_id: int) -> None: assert user.get("default_password") self.client.login(user["username"], user["default_password"]) - def update_vote_service_auth_data(self, auth_data: AuthData) -> None: - self.vote_service.set_authentication( - auth_data["access_token"], auth_data["refresh_id"] - ) - def get_application(self) -> OpenSlidesBackendWSGIApplication: raise NotImplementedError() diff --git a/tests/system/migrations/test_0100_init_reldb.py b/tests/system/migrations/test_0100_init_reldb.py index 981a7aa4fa..5118bc07a4 100644 --- a/tests/system/migrations/test_0100_init_reldb.py +++ b/tests/system/migrations/test_0100_init_reldb.py @@ -12,6 +12,8 @@ from unittest.mock import Mock, patch from zoneinfo import ZoneInfo +import pytest + from meta.dev.src.generate_sql_schema import GenerateCodeBlocks from openslides_backend.http.application import OpenSlidesBackendWSGIApplication from openslides_backend.http.views import ActionView @@ -64,6 +66,7 @@ data: dict[str, any] = {} +@pytest.mark.skip(reason="Waiting for the migration framework.") class TestMigration100(BaseMigrationTestCase): """ Commentary: diff --git a/tests/system/presenter/test_check_database.py b/tests/system/presenter/test_check_database.py index 9c3ccd3ed0..b710571ae6 100644 --- a/tests/system/presenter/test_check_database.py +++ b/tests/system/presenter/test_check_database.py @@ -1,9 +1,8 @@ -from decimal import Decimal from typing import Any from psycopg.types.json import Jsonb -from openslides_backend.models.models import Meeting +from openslides_backend.models.models import Meeting, Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.shared.util import ONE_ORGANIZATION_FQID, ONE_ORGANIZATION_ID @@ -84,14 +83,8 @@ def get_meeting_defaults(self) -> dict[str, Any]: "motions_supporters_min_amount": 0, "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": False, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_backend": "fast", - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, "users_enable_presence_view": False, "users_enable_vote_weight": False, "users_enable_vote_delegations": True, @@ -102,19 +95,12 @@ def get_meeting_defaults(self) -> dict[str, Any]: "users_email_subject": "OpenSlides access data", "users_email_body": "blablabla", "assignments_export_title": "Elections", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": False, - "assignment_poll_enable_max_votes_per_option": False, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_backend": "fast", - "poll_default_type": "analog", - "poll_default_onehundred_percent_base": "YNA", - "poll_default_backend": "fast", + "assignment_poll_default_method": "selection", + "topic_poll_default_method": "selection", + "poll_enable_max_votes_per_option": False, "poll_default_live_voting_enabled": False, + "poll_default_allow_invalid": False, "poll_couple_countdown": True, } @@ -285,14 +271,15 @@ def test_correct_relations(self) -> None: "motion_ids": [1], "motion_submitter_ids": [5], "list_of_speakers_ids": [6, 11], - "vote_ids": [7], - "option_ids": [8], "assignment_candidate_ids": [9], "assignment_ids": [10], # relation fields. "is_archived_in_organization_id": 1, "template_for_organization_id": 1, "default_meeting_for_committee_id": 1, + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, "organization_tag_ids": [1], "user_ids": [1, 2, 3, 4, 5, 6], "present_user_ids": [2], @@ -302,6 +289,7 @@ def test_correct_relations(self) -> None: "font_bold_id": 2, "font_regular_id": 4, "meeting_user_ids": [11, 12, 13, 14, 15, 16], + "poll_default_ids": [1, 2, 3], **{field: [1] for field in Meeting.all_default_projectors()}, **self.get_meeting_defaults(), }, @@ -311,6 +299,7 @@ def test_correct_relations(self) -> None: "weight": 1, "default_group_for_meeting_id": 1, "meeting_user_ids": [11, 12, 13, 14, 15, 16], + "used_in_meeting_poll_default_ids": [1, 2], }, "group/2": { "meeting_id": 1, @@ -318,6 +307,7 @@ def test_correct_relations(self) -> None: "weight": 1, "admin_group_for_meeting_id": 1, "meeting_mediafile_inherited_access_group_ids": [4], + "used_in_meeting_poll_default_ids": [1, 2, 3], }, "user/1": { "username": "no", @@ -343,17 +333,11 @@ def test_correct_relations(self) -> None: ), "user/4": self.get_new_user( "vote_user", - { - "meeting_user_ids": [14], - "vote_ids": [7], - }, + {"meeting_user_ids": [14]}, ), "user/5": self.get_new_user( "delegated_user", - { - "meeting_user_ids": [15], - "delegated_vote_ids": [7], - }, + {"meeting_user_ids": [15]}, ), "user/6": self.get_new_user( "candidate_user", @@ -386,11 +370,13 @@ def test_correct_relations(self) -> None: "user_id": 4, "meeting_id": 1, "group_ids": [1], + "acting_ballot_ids": [7], }, "meeting_user/15": { "user_id": 5, "meeting_id": 1, "group_ids": [1], + "represented_ballot_ids": [7], }, "meeting_user/16": { "user_id": 6, @@ -509,19 +495,51 @@ def test_correct_relations(self) -> None: "content_object_id": "motion/1", "meeting_id": 1, }, - "vote/7": { - "user_token": "test", - "option_id": 8, - "user_id": 4, - "delegated_user_id": 5, + "meeting_poll_default/1": { "meeting_id": 1, - "weight": Decimal("1.000000"), - "value": "Y", + "used_as_assignment_poll_config_in_meeting_id": 1, + "group_ids": [1, 2], + "sort_result_by_votes": True, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": False, }, - "option/8": { - "vote_ids": [7], + "meeting_poll_default/2": { "meeting_id": 1, - "weight": 10000, + "used_as_motion_poll_config_in_meeting_id": 1, + "group_ids": [1, 2], + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": True, + }, + "meeting_poll_default/3": { + "meeting_id": 1, + "used_as_topic_poll_config_in_meeting_id": 1, + "group_ids": [2], + "sort_result_by_votes": True, + "visibility": "named", + "onehundred_percent_base": "valid", + "display_chart": "pie", + }, + "poll/7": { + "title": "Poll 7", + "meeting_id": 1, + "content_object_id": "motion/1", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_rating_approval/7", + "state": Poll.STATE_STARTED, + }, + "poll_config_rating_approval/7": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_ballot/8": { + "poll_id": 7, + "poll_ballot_user_id": 12, + }, + "poll_ballot_user/12": { + "poll_id": 7, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 15, }, "assignment_candidate/9": { "weight": 10000, diff --git a/tests/system/presenter/test_check_database_all.py b/tests/system/presenter/test_check_database_all.py index a65461a047..b6d7fe884e 100644 --- a/tests/system/presenter/test_check_database_all.py +++ b/tests/system/presenter/test_check_database_all.py @@ -1,9 +1,8 @@ from datetime import datetime, timedelta -from decimal import Decimal from typing import Any from openslides_backend.action.action_worker import ActionWorkerState -from openslides_backend.models.models import Meeting +from openslides_backend.models.models import Meeting, Poll from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.shared.util import ONE_ORGANIZATION_FQID, ONE_ORGANIZATION_ID @@ -90,14 +89,8 @@ def get_meeting_defaults(self) -> dict[str, Any]: "motions_supporters_min_amount": 0, "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": False, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_backend": "fast", - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, "users_enable_presence_view": False, "users_enable_vote_weight": False, "users_enable_vote_delegations": True, @@ -109,19 +102,12 @@ def get_meeting_defaults(self) -> dict[str, Any]: "users_email_subject": "OpenSlides access data", "users_email_body": "blablabla", "assignments_export_title": "Elections", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": False, - "assignment_poll_enable_max_votes_per_option": False, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_backend": "fast", - "poll_default_type": "analog", - "poll_default_onehundred_percent_base": "YNA", - "poll_default_backend": "fast", + "assignment_poll_default_method": "selection", + "topic_poll_default_method": "selection", + "poll_enable_max_votes_per_option": False, "poll_default_live_voting_enabled": False, + "poll_default_allow_invalid": False, "poll_couple_countdown": True, } @@ -381,14 +367,15 @@ def test_correct_relations(self) -> None: "motion_ids": [1], "motion_submitter_ids": [5], "list_of_speakers_ids": [6, 11], - "vote_ids": [7], - "option_ids": [8], "assignment_candidate_ids": [9], "assignment_ids": [10], # relation fields. "is_archived_in_organization_id": 1, "template_for_organization_id": 1, "default_meeting_for_committee_id": 1, + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, "organization_tag_ids": [1], "user_ids": [1, 2, 3, 4, 5, 6], "present_user_ids": [2], @@ -397,6 +384,7 @@ def test_correct_relations(self) -> None: "logo_web_header_id": 1, "font_bold_id": 2, "meeting_user_ids": [11, 12, 13, 14, 15, 16], + "poll_default_ids": [1, 2, 3], **{field: [1] for field in Meeting.all_default_projectors()}, **self.get_meeting_defaults(), }, @@ -407,12 +395,14 @@ def test_correct_relations(self) -> None: "default_group_for_meeting_id": 1, "meeting_user_ids": [11, 12, 13, 14, 15, 16], "meeting_mediafile_access_group_ids": [4], + "used_in_meeting_poll_default_ids": [1, 2], }, "group/2": { "meeting_id": 1, "name": "admin group", "weight": 1, "admin_group_for_meeting_id": 1, + "used_in_meeting_poll_default_ids": [1, 2, 3], }, "user/1": { "meeting_user_ids": [11], @@ -438,14 +428,14 @@ def test_correct_relations(self) -> None: ), "user/4": self.get_new_user( "vote_user", - {"meeting_user_ids": [14], "vote_ids": [7], "is_active": False}, + { + "meeting_user_ids": [14], + "is_active": False, + }, ), "user/5": self.get_new_user( "delegated_user", - { - "meeting_user_ids": [15], - "delegated_vote_ids": [7], - }, + {"meeting_user_ids": [15]}, ), "user/6": self.get_new_user( "candidate_user", @@ -473,11 +463,13 @@ def test_correct_relations(self) -> None: "meeting_id": 1, "user_id": 4, "group_ids": [1], + "acting_ballot_ids": [7], }, "meeting_user/15": { "meeting_id": 1, "user_id": 5, "group_ids": [1], + "represented_ballot_ids": [7], }, "meeting_user/16": { "meeting_id": 1, @@ -579,19 +571,51 @@ def test_correct_relations(self) -> None: "content_object_id": "motion/1", "meeting_id": 1, }, - "vote/7": { - "user_token": "test", - "option_id": 8, - "user_id": 4, - "delegated_user_id": 5, + "meeting_poll_default/1": { "meeting_id": 1, - "weight": Decimal("1.000000"), - "value": "Y", + "used_as_assignment_poll_config_in_meeting_id": 1, + "group_ids": [1, 2], + "sort_result_by_votes": True, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": False, }, - "option/8": { - "vote_ids": [7], + "meeting_poll_default/2": { "meeting_id": 1, - "weight": 10000, + "used_as_motion_poll_config_in_meeting_id": 1, + "group_ids": [1, 2], + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": True, + }, + "meeting_poll_default/3": { + "meeting_id": 1, + "used_as_topic_poll_config_in_meeting_id": 1, + "group_ids": [2], + "sort_result_by_votes": True, + "visibility": "named", + "onehundred_percent_base": "valid", + "display_chart": "pie", + }, + "poll/7": { + "title": "Poll 1", + "meeting_id": 1, + "content_object_id": "motion/1", + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_rating_approval/7", + "state": Poll.STATE_STARTED, + }, + "poll_config_rating_approval/7": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_ballot/8": { + "poll_id": 7, + "poll_ballot_user_id": 12, + }, + "poll_ballot_user/12": { + "poll_id": 7, + "acting_meeting_user_id": 14, + "represented_meeting_user_id": 15, }, "assignment_candidate/9": { "weight": 10000, diff --git a/tests/system/presenter/test_export_meeting.py b/tests/system/presenter/test_export_meeting.py index 30bac478e4..317f47418e 100644 --- a/tests/system/presenter/test_export_meeting.py +++ b/tests/system/presenter/test_export_meeting.py @@ -60,9 +60,8 @@ def test_correct(self) -> None: "motion_category", "motion_block", "motion_change_recommendation", + "meeting_poll_default", "poll", - "option", - "vote", "assignment", "assignment_candidate", "mediafile", @@ -218,62 +217,247 @@ def test_export_meeting_with_ex_user(self) -> None: def test_export_meeting_find_special_users(self) -> None: """Find users in: - Collection | Field - meeting | present_user_ids - motion | supporter_meeting_user_ids - poll | voted_ids - vote | delegated_meeting_user_id + Collection | Field + meeting | present_user_ids + + Find meeting_users in: + Collection | Field + poll | voted_ids + ballot | acting_meeting_user_id + ballot | represented_meeting_user_id + poll_option | meeting_user_id """ self.create_motion(1, 30) self.set_models( { "meeting/1": {"present_user_ids": [11]}, - "group/1": {"meeting_user_ids": [112, 114]}, + "group/1": {"meeting_user_ids": [112, 113, 114]}, "user/11": {"username": "exuser11"}, "user/12": {"username": "exuser12"}, "user/13": {"username": "exuser13"}, "user/14": {"username": "exuser14"}, - "meeting_user/112": { - "meeting_id": 1, - "user_id": 12, - }, + "meeting_user/112": {"meeting_id": 1, "user_id": 12}, "motion_supporter/1": { "motion_id": 30, "meeting_id": 1, "meeting_user_id": 112, }, + "meeting_user/113": {"meeting_id": 1, "user_id": 13}, "meeting_user/114": { "meeting_id": 1, "user_id": 14, }, "poll/80": { "title": "Poll 80", - "type": Poll.TYPE_NAMED, - "backend": "fast", - "pollmethod": "YNA", - "onehundred_percent_base": "YNA", "meeting_id": 1, "content_object_id": "motion/30", - "state": Poll.STATE_PUBLISHED, - "voted_ids": [13], + "visibility": Poll.VISIBILITY_NAMED, + "config_id": "poll_config_approval/90", + "state": Poll.STATE_FINISHED, }, - "vote/120": { - "meeting_id": 1, - "delegated_user_id": 14, - "user_id": 14, - "user_token": "asdfgh", - "option_id": 1, - "weight": Decimal("1.000000"), - "value": "Y", + "poll_config_approval/90": { + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID + }, + "poll_option/100": {"poll_id": 80, "meeting_user_id": 113}, + "poll_ballot/120": { + "poll_id": 80, + "value": "yes", + "poll_ballot_user_id": 130, + }, + "poll_ballot_user/130": { + "poll_id": 80, + "represented_meeting_user_id": 114, + "acting_meeting_user_id": 114, }, - "option/1": {"meeting_id": 1}, } ) status_code, data = self.request("export_meeting", {"meeting_id": 1}) assert status_code == 200 assert data["meeting"]["1"].get("user_ids") is None - for id_ in ("11", "12", "13", "14"): - assert data["user"][id_] + for id_ in range(11, 15): + assert data["user"][str(id_)] + for id_ in range(112, 115): + assert data["meeting_user"][str(id_)] + + assert data["meeting"]["1"]["present_user_ids"] == [11] + assert data["user"]["11"]["is_present_in_meeting_ids"] == [1] + + assert data["poll_option"]["100"]["meeting_user_id"] == 113 + assert data["meeting_user"]["113"]["poll_option_ids"] == [100] + + assert data["poll"]["80"]["ballot_ids"] == [120] + assert data["poll"]["80"]["ballot_user_ids"] == [130] + assert data["poll_ballot_user"]["130"]["acting_meeting_user_id"] == 114 + assert data["poll_ballot_user"]["130"]["represented_meeting_user_id"] == 114 + assert data["meeting_user"]["114"]["acting_ballot_ids"] == [130] + assert data["meeting_user"]["114"]["represented_ballot_ids"] == [130] + + def create_assignment(self, meeting_id: int, base: int) -> None: + self.set_models( + { + f"assignment/{base}": { + "title": "just do it", + "meeting_id": meeting_id, + }, + f"list_of_speakers/{base + 100}": { + "content_object_id": f"assignment/{base}", + "meeting_id": meeting_id, + }, + } + ) + + def test_export_meeting_find_poll_defaults(self) -> None: + poll_defaults_data = { + "meeting_poll_default/1": { + "meeting_id": 1, + "used_as_assignment_poll_config_in_meeting_id": 1, + "group_ids": [2, 3], + "sort_result_by_votes": True, + "visibility": "open", + "onehundred_percent_base": "valid", + "allow_abstain": True, + "allow_nota": True, + }, + "meeting_poll_default/2": { + "meeting_id": 1, + "used_as_motion_poll_config_in_meeting_id": 1, + "group_ids": [2, 3], + "sort_result_by_votes": False, + "visibility": "secret", + "onehundred_percent_base": "valid", + "allow_abstain": False, + "strike_out": True, + }, + "meeting_poll_default/3": { + "meeting_id": 1, + "used_as_topic_poll_config_in_meeting_id": 1, + "group_ids": [3], + "sort_result_by_votes": True, + "visibility": "named", + "onehundred_percent_base": "valid", + "display_chart": "pie", + }, + } + poll_default_config_ids = { + "assignment_poll_config_id": 1, + "motion_poll_config_id": 2, + "topic_poll_config_id": 3, + } + self.set_models( + { + "meeting/1": poll_default_config_ids, + "group/2": {"used_in_meeting_poll_default_ids": [1, 2]}, + "group/3": {"used_in_meeting_poll_default_ids": [1, 2, 3]}, + **poll_defaults_data, + } + ) + status_code, data = self.request("export_meeting", {"meeting_id": 1}) + assert status_code == 200 + for fqid, poll_default_data in poll_defaults_data.items(): + collection, id_ = fqid.split("/") + for k, v in poll_default_data.items(): + assert data[collection][id_][k] == v + assert data["meeting"]["1"]["poll_default_ids"] == [1, 2, 3] + for field_name, config_id in poll_default_config_ids.items(): + assert data["meeting"]["1"][field_name] == config_id + for group_id, config_ids in {2: [1, 2], 3: [1, 2, 3]}.items(): + assert ( + data["group"][str(group_id)]["used_in_meeting_poll_default_ids"] + == config_ids + ) + assert "used_in_meeting_poll_default_ids" not in data["group"]["1"] + + def test_export_meeting_find_poll_configs(self) -> None: + configs_data = { + "poll_config_approval/1": { + "poll_id": 11, + "allow_abstain": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_VALID, + }, + "poll_config_selection/2": { + "poll_id": 12, + "max_options_amount": 1, + "min_options_amount": 1, + "allow_nota": True, + "strike_out": True, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED, + }, + "poll_config_rating_score/3": { + "poll_id": 13, + "min_options_amount": 1, + "max_options_amount": 2, + "max_votes_per_option": 3, + "min_vote_sum": 1, + "max_vote_sum": 3, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED_PRESENT, + }, + "poll_config_rating_approval/4": { + "poll_id": 14, + "min_options_amount": 1, + "max_options_amount": 3, + "allow_abstain": False, + "onehundred_percent_base": Poll.ONEHUNDRED_PERCENT_BASE_ENTITLED_PRESENT, + }, + } + self.create_motion(1, 21) + self.create_motion(1, 24) + self.create_assignment(1, 22) + self.create_assignment(1, 23) + self.create_user_for_meeting(1) + self.create_user_for_meeting(1) + + self.set_models( + { + **configs_data, + "poll/11": { + "title": "Poll 11", + "meeting_id": 1, + "content_object_id": "motion/21", + "config_id": "poll_config_approval/1", + "visibility": Poll.VISIBILITY_NAMED, + "state": Poll.STATE_CREATED, + }, + "poll/12": { + "title": "Poll 12", + "meeting_id": 1, + "content_object_id": "assignment/22", + "config_id": "poll_config_selection/2", + "visibility": Poll.VISIBILITY_OPEN, + "state": Poll.STATE_STARTED, + }, + "poll/13": { + "title": "Poll 13", + "meeting_id": 1, + "content_object_id": "assignment/23", + "config_id": "poll_config_rating_score/3", + "visibility": Poll.VISIBILITY_SECRET, + "state": Poll.STATE_FINISHED, + }, + "poll/14": { + "title": "Poll 14", + "meeting_id": 1, + "content_object_id": "motion/24", + "config_id": "poll_config_rating_approval/4", + "visibility": Poll.VISIBILITY_NAMED, + "state": Poll.STATE_STARTED, + }, + "poll_option/121": {"poll_id": 12, "meeting_user_id": 1}, + "poll_option/122": {"poll_id": 12, "meeting_user_id": 2}, + "poll_option/131": {"poll_id": 13, "meeting_user_id": 1}, + "poll_option/132": {"poll_id": 13, "meeting_user_id": 2}, + "poll_option/141": {"poll_id": 14, "text": "Blue"}, + "poll_option/142": {"poll_id": 14, "text": "Green"}, + "poll_option/143": {"poll_id": 14, "text": "Red"}, + "group/1": {"poll_ids": [11, 12, 13, 14]}, + } + ) + status_code, data = self.request("export_meeting", {"meeting_id": 1}) + assert status_code == 200 + for config_fqid, config_data in configs_data.items(): + config_collection, config_id = config_fqid.split("/") + assert data["poll"][str(config_data["poll_id"])]["config_id"] == config_fqid + for k, v in config_data.items(): + assert data[config_collection][config_id][k] == v def test_with_structured_published_orga_files(self) -> None: self.set_models( @@ -359,14 +543,8 @@ def test_with_structured_published_orga_files(self) -> None: "motions_supporters_min_amount": 0, "motions_export_submitter_recommendation": True, "motions_export_follow_recommendation": False, - "motion_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "motion_poll_ballot_paper_number": 8, - "motion_poll_default_type": "pseudoanonymous", - "motion_poll_default_method": "YNA", - "motion_poll_default_onehundred_percent_base": "YNA", - "motion_poll_default_backend": "fast", - "motion_poll_projection_name_order_first": "last_name", - "motion_poll_projection_max_columns": 6, + "poll_projection_name_order_first": "last_name", + "poll_projection_max_columns": 6, "users_enable_presence_view": False, "users_enable_vote_weight": False, "users_enable_vote_delegations": True, @@ -377,19 +555,10 @@ def test_with_structured_published_orga_files(self) -> None: "users_email_subject": "OpenSlides access data", "users_email_body": "blablabla", "assignments_export_title": "Elections", - "assignment_poll_ballot_paper_selection": "CUSTOM_NUMBER", - "assignment_poll_ballot_paper_number": 8, "assignment_poll_add_candidates_to_list_of_speakers": False, - "assignment_poll_enable_max_votes_per_option": False, - "assignment_poll_sort_poll_result_by_votes": True, - "assignment_poll_default_type": "pseudoanonymous", - "assignment_poll_default_method": "Y", - "assignment_poll_default_onehundred_percent_base": "valid", - "assignment_poll_default_backend": "fast", - "poll_default_type": "analog", - "poll_default_onehundred_percent_base": "YNA", - "poll_default_backend": "fast", - "poll_default_live_voting_enabled": False, + "assignment_poll_default_method": "rating_approval", + "poll_enable_max_votes_per_option": False, + "topic_poll_default_method": "rating_approval", "poll_couple_countdown": True, }, "group/1": { diff --git a/tests/system/util.py b/tests/system/util.py index 896666a6bc..a215b2c9c3 100644 --- a/tests/system/util.py +++ b/tests/system/util.py @@ -1,13 +1,10 @@ -import copy import cProfile import os -from abc import abstractmethod from collections.abc import Callable from typing import Any, Self from unittest.mock import MagicMock, Mock, patch import pytest -from dependency_injector import providers from requests.models import Response as RequestsResponse from openslides_backend.http.application import OpenSlidesBackendWSGIApplication @@ -15,8 +12,6 @@ from openslides_backend.http.views.base_view import ROUTE_OPTIONS_ATTR, RouteFunction from openslides_backend.services.database.extended_database import ExtendedDatabase from openslides_backend.services.media.interface import MediaService -from openslides_backend.services.vote.adapter import VoteAdapter -from openslides_backend.services.vote.interface import VoteService from openslides_backend.shared.env import Environment, is_truthy from openslides_backend.shared.exceptions import MediaServiceException from openslides_backend.shared.interfaces.services import Services @@ -35,24 +30,6 @@ def convert_to_test_response(response: RequestsResponse) -> Response: ) -class TestVoteService(VoteService): - url: str - - @abstractmethod - def vote(self, data: dict[str, Any]) -> Response: ... - - -class TestVoteAdapter(VoteAdapter, TestVoteService): - def vote(self, data: dict[str, Any]) -> Response: - data_copy = copy.deepcopy(data) - del data_copy["id"] - response = self.make_request( - self.url.replace("internal", "system") + f"?id={data['id']}", - data_copy, - ) - return convert_to_test_response(response) - - def create_action_test_application() -> OpenSlidesBackendWSGIApplication: return create_test_application(ActionView) @@ -80,9 +57,6 @@ def create_test_application(view: type[View]) -> OpenSlidesBackendWSGIApplicatio config=env.get_service_url(), logging=MagicMock(), ) - services.vote = providers.Singleton( - TestVoteAdapter, services.config.vote_url, MagicMock() - ) mock_media_service = Mock(MediaService) mock_media_service.upload_mediafile = Mock( side_effect=side_effect_for_upload_method diff --git a/tests/unit/test_checker.py b/tests/unit/test_checker.py index 3350264621..7d618402be 100644 --- a/tests/unit/test_checker.py +++ b/tests/unit/test_checker.py @@ -170,7 +170,7 @@ def setUp(self) -> None: "default_projector_amendment_ids": [1], "default_projector_assignment_ids": [1], "default_projector_motion_ids": [1], - "default_projector_poll_ids": [1], + "default_projector_topic_poll_ids": [1], "motion_workflow_ids": [1], "motion_state_ids": [1], } @@ -202,7 +202,7 @@ def setUp(self) -> None: "used_as_default_projector_for_countdown_in_meeting_id": 1, "used_as_default_projector_for_assignment_poll_in_meeting_id": 1, "used_as_default_projector_for_motion_poll_in_meeting_id": 1, - "used_as_default_projector_for_poll_in_meeting_id": 1, + "used_as_default_projector_for_topic_poll_in_meeting_id": 1, } }, "motion_workflow": {