diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a68692..01ad513 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,35 +1,30 @@ { - "editor.formatOnSave": true, - "python.pythonPath": "./env/bin/python3", - "python.formatting.provider": "black", - "python.sortImports.args": [ - "-sp .isort.cfg" - ], - "python.linting.enabled": true, - "python.linting.pylintEnabled": false, - "python.linting.flake8Enabled": true, - "python.testing.pytestArgs": [ - "tests", - "--capture=sys" - ], - "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, - "python.testing.pytestEnabled": true, - "[python]": { - "editor.codeActionsOnSave": { - "source.organizeImports": true - } - }, - "[html]": { - "editor.formatOnSave": false - }, - "[javascript]": { - "editor.formatOnSave": false - }, - "[yaml]": { - "editor.formatOnSave": false - }, - "[sql]": { - "editor.formatOnSave": false + "editor.formatOnSave": true, + "python.pythonPath": "./env/bin/python3", + "python.formatting.provider": "black", + "isort.args": ["-sp", ".isort.cfg"], + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.testing.pytestArgs": ["tests", "--capture=sys"], + "python.testing.unittestEnabled": false, + "python.testing.nosetestsEnabled": false, + "python.testing.pytestEnabled": true, + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true } -} \ No newline at end of file + }, + "[html]": { + "editor.formatOnSave": false + }, + "[javascript]": { + "editor.formatOnSave": false + }, + "[yaml]": { + "editor.formatOnSave": false + }, + "[sql]": { + "editor.formatOnSave": false + } +} diff --git a/messages.pot b/messages.pot index 889c9bb..2929e3c 100644 --- a/messages.pot +++ b/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-04-27 15:10+0200\n" +"POT-Creation-Date: 2023-05-01 18:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -194,7 +194,7 @@ msgid "message" msgstr "" #: project/api/organization/resources.py:401 -#: project/views/admin_unit_member_invitation.py:85 +#: project/views/admin_unit_member_invitation.py:89 msgid "You have received an invitation" msgstr "" @@ -211,7 +211,7 @@ msgstr "" #: project/forms/admin.py:13 project/templates/_macros.html:1473 #: project/templates/layout.html:311 #: project/templates/widget/event_suggestion/create.html:204 -#: project/views/admin_unit.py:82 project/views/root.py:71 +#: project/views/admin_unit.py:83 project/views/root.py:71 msgid "Contact" msgstr "" @@ -247,6 +247,7 @@ msgstr "" #: project/forms/admin_unit_member.py:11 project/forms/admin_unit_member.py:23 #: project/forms/admin_unit_member.py:28 project/forms/event.py:107 #: project/forms/event_suggestion.py:38 project/forms/organizer.py:27 +#: project/forms/user.py:18 project/forms/user.py:23 #: project/templates/_macros.html:237 project/templates/_macros.html:1569 #: project/templates/admin/admin.html:27 project/templates/admin/email.html:4 #: project/templates/admin/email.html:66 project/templates/admin/users.html:19 @@ -474,14 +475,15 @@ msgstr "" msgid "Link Color" msgstr "" -#: project/forms/admin_unit.py:133 +#: project/forms/admin_unit.py:133 project/forms/user.py:17 msgid "Request deletion" msgstr "" -#: project/forms/admin_unit.py:138 +#: project/forms/admin_unit.py:138 project/forms/user.py:22 #: project/templates/admin_unit/cancel_deletion.html:6 #: project/templates/admin_unit/update.html:26 -#: project/templates/manage/events.html:49 +#: project/templates/manage/events.html:49 project/templates/profile.html:13 +#: project/templates/user/cancel_deletion.html:6 msgid "Cancel deletion" msgstr "" @@ -1324,7 +1326,7 @@ msgstr "" #: project/templates/_macros.html:590 project/templates/_macros.html:633 #: project/templates/_macros.html:765 #: project/templates/admin/admin_units.html:36 -#: project/templates/admin/users.html:32 +#: project/templates/admin/users.html:34 #: project/templates/manage/events.html:116 #: project/templates/manage/members.html:35 #: project/templates/manage/organizers.html:33 @@ -1539,7 +1541,7 @@ msgstr "" #: project/templates/oauth2_client/list.html:10 #: project/templates/oauth2_client/read.html:10 #: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4 -#: project/templates/profile.html:10 +#: project/templates/profile.html:17 msgid "Profile" msgstr "" @@ -1641,7 +1643,7 @@ msgstr "" #: project/templates/admin_unit/update.html:6 #: project/templates/admin_unit/update.html:30 #: project/templates/layout.html:259 project/templates/manage/widgets.html:11 -#: project/templates/manage/widgets.html:15 project/templates/profile.html:19 +#: project/templates/manage/widgets.html:15 project/templates/profile.html:32 msgid "Settings" msgstr "" @@ -1667,24 +1669,34 @@ msgstr "" #: project/templates/developer/read.html:4 #: project/templates/developer/read.html:8 project/templates/layout.html:319 -#: project/templates/profile.html:33 +#: project/templates/profile.html:46 msgid "Developer" msgstr "" -#: project/templates/profile.html:23 +#: project/templates/profile.html:12 project/views/admin_unit.py:89 +#: project/views/admin_unit_member_invitation.py:39 +msgid "Your account is scheduled for deletion." +msgstr "" + +#: project/templates/profile.html:26 +#: project/templates/user/request_deletion.html:6 +msgid "Delete account" +msgstr "" + +#: project/templates/profile.html:36 #: project/templates/user/notifications.html:4 #: project/templates/user/notifications.html:8 msgid "Notifications" msgstr "" -#: project/templates/profile.html:27 +#: project/templates/profile.html:40 msgid "Applications" msgstr "" #: project/templates/oauth2_client/list.html:4 #: project/templates/oauth2_client/list.html:11 #: project/templates/oauth2_client/read.html:11 -#: project/templates/profile.html:37 +#: project/templates/profile.html:50 msgid "OAuth2 clients" msgstr "" @@ -1702,7 +1714,7 @@ msgid "View" msgstr "" #: project/templates/admin/admin_units.html:37 -#: project/templates/admin/users.html:33 +#: project/templates/admin/users.html:35 #: project/templates/manage/events.html:117 #: project/templates/manage/members.html:21 #: project/templates/manage/members.html:36 @@ -1714,6 +1726,8 @@ msgid "Delete" msgstr "" #: project/templates/admin/delete_user.html:13 +#: project/templates/user/cancel_deletion.html:13 +#: project/templates/user/request_deletion.html:15 msgid "User" msgstr "" @@ -1800,7 +1814,6 @@ msgid "You have been invited to join %(admin_unit_name)s." msgstr "" #: project/templates/email/invitation_notice.html:5 -#: project/templates/email/organization_deletion_requested_notice.html:5 #: project/templates/email/organization_invitation_notice.html:5 msgid "Click here to view the invitation" msgstr "" @@ -1823,6 +1836,10 @@ msgstr "" msgid "%(admin_unit_name)s is scheduled for deletion." msgstr "" +#: project/templates/email/organization_deletion_requested_notice.html:5 +msgid "Click here below to cancel the deletion" +msgstr "" + #: project/templates/email/organization_invitation_accepted_notice.html:4 #, python-format msgid "" @@ -1889,6 +1906,15 @@ msgstr "" msgid "Click here to open the site" msgstr "" +#: project/templates/email/user_deletion_requested_notice.html:4 +#, python-format +msgid "%(user_email)s is scheduled for deletion." +msgstr "" + +#: project/templates/email/user_deletion_requested_notice.html:5 +msgid "Click the link below to cancel the deletion" +msgstr "" + #: project/templates/event/actions.html:5 #: project/templates/event/actions.html:22 msgid "Actions for event" @@ -2205,6 +2231,12 @@ msgstr "" msgid "Register for free" msgstr "" +#: project/templates/user/request_deletion.html:8 +msgid "" +"The account is not deleted immediately. After a period of time, the " +"account will be deleted. Until then, the deletion can be canceled." +msgstr "" + #: project/templates/widget/event_date/list.html:5 msgid "Widget" msgstr "" @@ -2229,8 +2261,8 @@ msgstr "" msgid "Organization successfully updated" msgstr "" -#: project/views/admin.py:85 project/views/admin_unit.py:182 -#: project/views/admin_unit.py:215 +#: project/views/admin.py:85 project/views/admin_unit.py:187 +#: project/views/admin_unit.py:220 msgid "Entered name does not match organization name" msgstr "" @@ -2239,7 +2271,7 @@ msgid "Organization successfully deleted" msgstr "" #: project/views/admin.py:113 project/views/manage.py:432 -#: project/views/user.py:28 +#: project/views/user.py:40 msgid "Settings successfully updated" msgstr "" @@ -2261,29 +2293,29 @@ msgstr "" msgid "Entered email does not match user email" msgstr "" -#: project/views/admin.py:237 +#: project/views/admin.py:236 msgid "User successfully deleted" msgstr "" -#: project/views/admin_unit.py:78 +#: project/views/admin_unit.py:79 msgid "" "Organizations cannot currently be created. The project is in a closed " "test phase. If you are interested, you can contact us." msgstr "" -#: project/views/admin_unit.py:128 +#: project/views/admin_unit.py:133 msgid "Organization successfully created" msgstr "" -#: project/views/admin_unit.py:158 +#: project/views/admin_unit.py:163 msgid "AdminUnit successfully updated" msgstr "" -#: project/views/admin_unit.py:245 +#: project/views/admin_unit.py:250 msgid "Organization invitation accepted" msgstr "" -#: project/views/admin_unit.py:259 +#: project/views/admin_unit.py:264 msgid "Organization deletion requested" msgstr "" @@ -2299,23 +2331,23 @@ msgstr "" msgid "Member successfully deleted" msgstr "" -#: project/views/admin_unit_member_invitation.py:38 +#: project/views/admin_unit_member_invitation.py:42 msgid "Invitation successfully accepted" msgstr "" -#: project/views/admin_unit_member_invitation.py:45 +#: project/views/admin_unit_member_invitation.py:49 msgid "Invitation successfully declined" msgstr "" -#: project/views/admin_unit_member_invitation.py:90 +#: project/views/admin_unit_member_invitation.py:94 msgid "Invitation successfully sent" msgstr "" -#: project/views/admin_unit_member_invitation.py:113 +#: project/views/admin_unit_member_invitation.py:117 msgid "Entered email does not match invitation email" msgstr "" -#: project/views/admin_unit_member_invitation.py:118 +#: project/views/admin_unit_member_invitation.py:122 msgid "Invitation successfully deleted" msgstr "" @@ -2470,6 +2502,14 @@ msgid "" "verified automatically." msgstr "" +#: project/views/user.py:84 project/views/user.py:111 +msgid "Entered email does not match your email" +msgstr "" + +#: project/views/user.py:130 +msgid "User deletion requested" +msgstr "" + #: project/views/utils.py:71 msgid "" "An entry with the entered values ​​already exists. Duplicate entries are " diff --git a/migrations/alembic.ini b/migrations/alembic.ini index ed8a140..6dab4c3 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -50,4 +50,5 @@ format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S [alembic:exclude] -tables = spatial_ref_sys +tables = spatial_ref_sys,layer,topology +indexes = idx_event_description,idx_event_fulltext,idx_event_name,idx_event_tags,idx_location_coordinate diff --git a/migrations/env.py b/migrations/env.py index 2615d73..c6a184c 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -44,19 +44,23 @@ def get_metadata(): return target_db.metadata -def exclude_tables_from_config(config_): - tables_ = config_.get("tables", None) - if tables_ is not None: - tables = tables_.split(",") - return tables +def exclude_items_from_config(section, key): + items_ = section.get(key, None) + if items_ is not None: + items = items_.split(",") + return items -exclude_tables = exclude_tables_from_config(config.get_section("alembic:exclude")) +config_exclude_section = config.get_section("alembic:exclude") +exclude_tables = exclude_items_from_config(config_exclude_section, "tables") +exclude_indexes = exclude_items_from_config(config_exclude_section, "indexes") def include_object(object, name, type_, reflected, compare_to): if type_ == "table" and name in exclude_tables: return False + elif type_ == "index" and name in exclude_indexes: + return False else: return True diff --git a/migrations/versions/58d8aae621e6_.py b/migrations/versions/58d8aae621e6_.py new file mode 100644 index 0000000..a987cf0 --- /dev/null +++ b/migrations/versions/58d8aae621e6_.py @@ -0,0 +1,65 @@ +"""empty message + +Revision ID: 58d8aae621e6 +Revises: cbac4166f9c0 +Create Date: 2023-04-28 11:26:33.237832 + +""" +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + +from project import dbtypes + +# revision identifiers, used by Alembic. +revision = "58d8aae621e6" +down_revision = "cbac4166f9c0" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + op.f("uq_eventlist_name"), "eventlist", ["name", "admin_unit_id"] + ) + op.add_column( + "oauth2_token", + sa.Column( + "access_token_revoked_at", sa.Integer(), nullable=False, server_default="0" + ), + ) + op.add_column( + "oauth2_token", + sa.Column( + "refresh_token_revoked_at", sa.Integer(), nullable=False, server_default="0" + ), + ) + op.drop_column("oauth2_token", "revoked") + op.alter_column( + "user", + "newsletter_enabled", + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("true"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "user", + "newsletter_enabled", + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("true"), + ) + op.add_column( + "oauth2_token", + sa.Column("revoked", sa.BOOLEAN(), autoincrement=False, nullable=True), + ) + op.drop_column("oauth2_token", "refresh_token_revoked_at") + op.drop_column("oauth2_token", "access_token_revoked_at") + op.drop_constraint(op.f("uq_eventlist_name"), "eventlist", type_="unique") + # ### end Alembic commands ### diff --git a/migrations/versions/5fde26e1904d_.py b/migrations/versions/5fde26e1904d_.py new file mode 100644 index 0000000..cb76811 --- /dev/null +++ b/migrations/versions/5fde26e1904d_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: 5fde26e1904d +Revises: 58d8aae621e6 +Create Date: 2023-04-28 13:04:22.142011 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.sql.elements import conv +from sqlalchemy.sql.naming import ConventionDict, _get_convention + +from project import dbtypes + +# revision identifiers, used by Alembic. +revision = "5fde26e1904d" +down_revision = "58d8aae621e6" +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + ctx = op.get_context() + existing_metadata = sa.schema.MetaData() + existing_metadata.reflect(bind=conn) + target_metadata = ctx.opts["target_metadata"] + + for table_name, table in existing_metadata.tables.items(): + if table_name not in target_metadata.tables: + continue + + for c in table.constraints: + existing_name = c.name + if not existing_name: + continue + + convention = _get_convention(target_metadata.naming_convention, type(c)) + + if not convention: + continue + + target_c_name = conv( + convention % ConventionDict(c, table, target_metadata.naming_convention) + ) + if not target_c_name: + continue + + if existing_name != target_c_name: + op.execute( + f"ALTER TABLE public.{table_name} RENAME CONSTRAINT {existing_name} to {target_c_name};" + ) + + +def downgrade(): + pass diff --git a/migrations/versions/cceaf9b28134_.py b/migrations/versions/cceaf9b28134_.py new file mode 100644 index 0000000..9614e45 --- /dev/null +++ b/migrations/versions/cceaf9b28134_.py @@ -0,0 +1,596 @@ +"""empty message + +Revision ID: cceaf9b28134 +Revises: d952cd5df596 +Create Date: 2023-05-01 18:02:53.515904 + +""" +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + +from project import dbtypes + +# revision identifiers, used by Alembic. +revision = "cceaf9b28134" +down_revision = "d952cd5df596" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "fk_adminunit_created_by_id_user", "adminunit", type_="foreignkey" + ) + op.drop_constraint( + "fk_adminunit_updated_by_id_user", "adminunit", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_adminunit_updated_by_id_user"), + "adminunit", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_adminunit_created_by_id_user"), + "adminunit", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_adminunitinvitation_updated_by_id_user", + "adminunitinvitation", + type_="foreignkey", + ) + op.drop_constraint( + "fk_adminunitinvitation_created_by_id_user", + "adminunitinvitation", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_adminunitinvitation_updated_by_id_user"), + "adminunitinvitation", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_adminunitinvitation_created_by_id_user"), + "adminunitinvitation", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_adminunitrelation_updated_by_id_user", + "adminunitrelation", + type_="foreignkey", + ) + op.drop_constraint( + "fk_adminunitrelation_created_by_id_user", + "adminunitrelation", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_adminunitrelation_updated_by_id_user"), + "adminunitrelation", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_adminunitrelation_created_by_id_user"), + "adminunitrelation", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_customwidget_updated_by_id_user", "customwidget", type_="foreignkey" + ) + op.drop_constraint( + "fk_customwidget_created_by_id_user", "customwidget", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_customwidget_updated_by_id_user"), + "customwidget", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_customwidget_created_by_id_user"), + "customwidget", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint("fk_event_updated_by_id_user", "event", type_="foreignkey") + op.drop_constraint("fk_event_created_by_id_user", "event", type_="foreignkey") + op.create_foreign_key( + op.f("fk_event_created_by_id_user"), + "event", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_event_updated_by_id_user"), + "event", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventlist_created_by_id_user", "eventlist", type_="foreignkey" + ) + op.drop_constraint( + "fk_eventlist_updated_by_id_user", "eventlist", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_eventlist_updated_by_id_user"), + "eventlist", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventlist_created_by_id_user"), + "eventlist", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventorganizer_updated_by_id_user", "eventorganizer", type_="foreignkey" + ) + op.drop_constraint( + "fk_eventorganizer_created_by_id_user", "eventorganizer", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_eventorganizer_updated_by_id_user"), + "eventorganizer", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventorganizer_created_by_id_user"), + "eventorganizer", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventplace_created_by_id_user", "eventplace", type_="foreignkey" + ) + op.drop_constraint( + "fk_eventplace_updated_by_id_user", "eventplace", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_eventplace_created_by_id_user"), + "eventplace", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventplace_updated_by_id_user"), + "eventplace", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventreference_created_by_id_user", "eventreference", type_="foreignkey" + ) + op.drop_constraint( + "fk_eventreference_updated_by_id_user", "eventreference", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_eventreference_updated_by_id_user"), + "eventreference", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventreference_created_by_id_user"), + "eventreference", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventreferencerequest_updated_by_id_user", + "eventreferencerequest", + type_="foreignkey", + ) + op.drop_constraint( + "fk_eventreferencerequest_created_by_id_user", + "eventreferencerequest", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_eventreferencerequest_created_by_id_user"), + "eventreferencerequest", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventreferencerequest_updated_by_id_user"), + "eventreferencerequest", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint( + "fk_eventsuggestion_created_by_id_user", "eventsuggestion", type_="foreignkey" + ) + op.drop_constraint( + "fk_eventsuggestion_updated_by_id_user", "eventsuggestion", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_eventsuggestion_created_by_id_user"), + "eventsuggestion", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_eventsuggestion_updated_by_id_user"), + "eventsuggestion", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint("fk_image_updated_by_id_user", "image", type_="foreignkey") + op.drop_constraint("fk_image_created_by_id_user", "image", type_="foreignkey") + op.create_foreign_key( + op.f("fk_image_updated_by_id_user"), + "image", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_image_created_by_id_user"), + "image", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint("fk_location_created_by_id_user", "location", type_="foreignkey") + op.drop_constraint("fk_location_updated_by_id_user", "location", type_="foreignkey") + op.create_foreign_key( + op.f("fk_location_created_by_id_user"), + "location", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_location_updated_by_id_user"), + "location", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.drop_constraint("fk_settings_updated_by_id_user", "settings", type_="foreignkey") + op.drop_constraint("fk_settings_created_by_id_user", "settings", type_="foreignkey") + op.create_foreign_key( + op.f("fk_settings_created_by_id_user"), + "settings", + "user", + ["created_by_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + op.f("fk_settings_updated_by_id_user"), + "settings", + "user", + ["updated_by_id"], + ["id"], + ondelete="SET NULL", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_settings_updated_by_id_user"), "settings", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_settings_created_by_id_user"), "settings", type_="foreignkey" + ) + op.create_foreign_key( + "fk_settings_created_by_id_user", "settings", "user", ["created_by_id"], ["id"] + ) + op.create_foreign_key( + "fk_settings_updated_by_id_user", "settings", "user", ["updated_by_id"], ["id"] + ) + op.drop_constraint( + op.f("fk_location_updated_by_id_user"), "location", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_location_created_by_id_user"), "location", type_="foreignkey" + ) + op.create_foreign_key( + "fk_location_updated_by_id_user", "location", "user", ["updated_by_id"], ["id"] + ) + op.create_foreign_key( + "fk_location_created_by_id_user", "location", "user", ["created_by_id"], ["id"] + ) + op.drop_constraint(op.f("fk_image_created_by_id_user"), "image", type_="foreignkey") + op.drop_constraint(op.f("fk_image_updated_by_id_user"), "image", type_="foreignkey") + op.create_foreign_key( + "fk_image_created_by_id_user", "image", "user", ["created_by_id"], ["id"] + ) + op.create_foreign_key( + "fk_image_updated_by_id_user", "image", "user", ["updated_by_id"], ["id"] + ) + op.drop_constraint( + op.f("fk_eventsuggestion_updated_by_id_user"), + "eventsuggestion", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_eventsuggestion_created_by_id_user"), + "eventsuggestion", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_eventsuggestion_updated_by_id_user", + "eventsuggestion", + "user", + ["updated_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventsuggestion_created_by_id_user", + "eventsuggestion", + "user", + ["created_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_eventreferencerequest_updated_by_id_user"), + "eventreferencerequest", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_eventreferencerequest_created_by_id_user"), + "eventreferencerequest", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_eventreferencerequest_created_by_id_user", + "eventreferencerequest", + "user", + ["created_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventreferencerequest_updated_by_id_user", + "eventreferencerequest", + "user", + ["updated_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_eventreference_created_by_id_user"), + "eventreference", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_eventreference_updated_by_id_user"), + "eventreference", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_eventreference_updated_by_id_user", + "eventreference", + "user", + ["updated_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventreference_created_by_id_user", + "eventreference", + "user", + ["created_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_eventplace_updated_by_id_user"), "eventplace", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_eventplace_created_by_id_user"), "eventplace", type_="foreignkey" + ) + op.create_foreign_key( + "fk_eventplace_updated_by_id_user", + "eventplace", + "user", + ["updated_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventplace_created_by_id_user", + "eventplace", + "user", + ["created_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_eventorganizer_created_by_id_user"), + "eventorganizer", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_eventorganizer_updated_by_id_user"), + "eventorganizer", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_eventorganizer_created_by_id_user", + "eventorganizer", + "user", + ["created_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventorganizer_updated_by_id_user", + "eventorganizer", + "user", + ["updated_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_eventlist_created_by_id_user"), "eventlist", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_eventlist_updated_by_id_user"), "eventlist", type_="foreignkey" + ) + op.create_foreign_key( + "fk_eventlist_updated_by_id_user", + "eventlist", + "user", + ["updated_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_eventlist_created_by_id_user", + "eventlist", + "user", + ["created_by_id"], + ["id"], + ) + op.drop_constraint(op.f("fk_event_updated_by_id_user"), "event", type_="foreignkey") + op.drop_constraint(op.f("fk_event_created_by_id_user"), "event", type_="foreignkey") + op.create_foreign_key( + "fk_event_created_by_id_user", "event", "user", ["created_by_id"], ["id"] + ) + op.create_foreign_key( + "fk_event_updated_by_id_user", "event", "user", ["updated_by_id"], ["id"] + ) + op.drop_constraint( + op.f("fk_customwidget_created_by_id_user"), "customwidget", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_customwidget_updated_by_id_user"), "customwidget", type_="foreignkey" + ) + op.create_foreign_key( + "fk_customwidget_created_by_id_user", + "customwidget", + "user", + ["created_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_customwidget_updated_by_id_user", + "customwidget", + "user", + ["updated_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_adminunitrelation_created_by_id_user"), + "adminunitrelation", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_adminunitrelation_updated_by_id_user"), + "adminunitrelation", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_adminunitrelation_created_by_id_user", + "adminunitrelation", + "user", + ["created_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_adminunitrelation_updated_by_id_user", + "adminunitrelation", + "user", + ["updated_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_adminunitinvitation_created_by_id_user"), + "adminunitinvitation", + type_="foreignkey", + ) + op.drop_constraint( + op.f("fk_adminunitinvitation_updated_by_id_user"), + "adminunitinvitation", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_adminunitinvitation_created_by_id_user", + "adminunitinvitation", + "user", + ["created_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_adminunitinvitation_updated_by_id_user", + "adminunitinvitation", + "user", + ["updated_by_id"], + ["id"], + ) + op.drop_constraint( + op.f("fk_adminunit_created_by_id_user"), "adminunit", type_="foreignkey" + ) + op.drop_constraint( + op.f("fk_adminunit_updated_by_id_user"), "adminunit", type_="foreignkey" + ) + op.create_foreign_key( + "fk_adminunit_updated_by_id_user", + "adminunit", + "user", + ["updated_by_id"], + ["id"], + ) + op.create_foreign_key( + "fk_adminunit_created_by_id_user", + "adminunit", + "user", + ["created_by_id"], + ["id"], + ) + # ### end Alembic commands ### diff --git a/migrations/versions/d952cd5df596_.py b/migrations/versions/d952cd5df596_.py new file mode 100644 index 0000000..de0a682 --- /dev/null +++ b/migrations/versions/d952cd5df596_.py @@ -0,0 +1,53 @@ +"""empty message + +Revision ID: d952cd5df596 +Revises: 5fde26e1904d +Create Date: 2023-05-01 17:27:48.768633 + +""" +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + +from project import dbtypes + +# revision identifiers, used by Alembic. +revision = "d952cd5df596" +down_revision = "5fde26e1904d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "fk_adminunitmember_user_id_user", "adminunitmember", type_="foreignkey" + ) + op.create_foreign_key( + op.f("fk_adminunitmember_user_id_user"), + "adminunitmember", + "user", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) + op.add_column( + "user", sa.Column("deletion_requested_at", sa.DateTime(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "deletion_requested_at") + op.drop_constraint( + op.f("fk_adminunitmember_user_id_user"), "adminunitmember", type_="foreignkey" + ) + op.create_foreign_key( + "fk_adminunitmember_user_id_user", + "adminunitmember", + "user", + ["user_id"], + ["id"], + ) + # ### end Alembic commands ### diff --git a/project/__init__.py b/project/__init__.py index 9e8159f..7f309e4 100644 --- a/project/__init__.py +++ b/project/__init__.py @@ -193,7 +193,7 @@ convention = { } metadata = MetaData(naming_convention=convention) db = SQLAlchemy(app, metadata=metadata) -migrate = Migrate(app, db) +migrate = Migrate(app, db, render_as_batch=False) # Celery tasks from project import celery_tasks diff --git a/project/celery_tasks.py b/project/celery_tasks.py index 883f244..26bf848 100644 --- a/project/celery_tasks.py +++ b/project/celery_tasks.py @@ -11,6 +11,9 @@ def setup_periodic_tasks(sender, **kwargs): sender.add_periodic_task( crontab(hour=0, minute=30), delete_admin_units_with_due_request_task ) + sender.add_periodic_task( + crontab(hour=0, minute=40), delete_user_with_due_request_task + ) sender.add_periodic_task(crontab(hour=1, minute=0), update_recurring_dates_task) sender.add_periodic_task(crontab(hour=2, minute=0), dump_all_task) sender.add_periodic_task(crontab(hour=3, minute=0), seo_generate_sitemap_task) @@ -97,6 +100,36 @@ def delete_admin_unit_task(admin_unit_id): delete_admin_unit(admin_unit) +@celery.task( + acks_late=True, + reject_on_worker_lost=True, +) +def delete_user_with_due_request_task(): + from project.services.user import get_users_with_due_delete_request + + users = get_users_with_due_delete_request() + + if not users: + return + + group(delete_user_task.s(user.id) for user in users).delay() + + +@celery.task( + acks_late=True, + reject_on_worker_lost=True, +) +def delete_user_task(user_id): + from project.services.user import delete_user, get_user + + user = get_user(user_id) + + if not user: + return + + delete_user(user) + + @celery.task( acks_late=True, reject_on_worker_lost=True, diff --git a/project/forms/user.py b/project/forms/user.py index 3cd886a..5e85688 100644 --- a/project/forms/user.py +++ b/project/forms/user.py @@ -1,7 +1,7 @@ from flask_babel import lazy_gettext from flask_wtf import FlaskForm -from wtforms import BooleanField, SubmitField -from wtforms.validators import Optional +from wtforms import BooleanField, EmailField, SubmitField +from wtforms.validators import DataRequired, Optional class NotificationForm(FlaskForm): @@ -11,3 +11,13 @@ class NotificationForm(FlaskForm): validators=[Optional()], ) submit = SubmitField(lazy_gettext("Save")) + + +class RequestUserDeletionForm(FlaskForm): + submit = SubmitField(lazy_gettext("Request deletion")) + email = EmailField(lazy_gettext("Email"), validators=[DataRequired()]) + + +class CancelUserDeletionForm(FlaskForm): + submit = SubmitField(lazy_gettext("Cancel deletion")) + email = EmailField(lazy_gettext("Email"), validators=[DataRequired()]) diff --git a/project/models/admin_unit.py b/project/models/admin_unit.py index 4c5624f..b3ab7c9 100644 --- a/project/models/admin_unit.py +++ b/project/models/admin_unit.py @@ -48,6 +48,13 @@ class AdminUnitMember(db.Model): admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) user = db.relationship("User", backref=db.backref("adminunitmembers", lazy=True)) + user_id = db.Column( + db.Integer, db.ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user = db.relationship( + "User", + backref=db.backref("adminunitmembers", cascade="all, delete-orphan", lazy=True), + ) roles = relationship( "AdminUnitMemberRole", secondary="adminunitmemberroles_members", diff --git a/project/models/event_list.py b/project/models/event_list.py index 2254146..ba9fadf 100644 --- a/project/models/event_list.py +++ b/project/models/event_list.py @@ -6,11 +6,7 @@ from project.models.trackable_mixin import TrackableMixin class EventList(db.Model, TrackableMixin): __tablename__ = "eventlist" - __table_args__ = ( - UniqueConstraint( - "name", "admin_unit_id", name="eventreference_name_admin_unit_id" - ), - ) + __table_args__ = (UniqueConstraint("name", "admin_unit_id"),) id = Column(Integer(), primary_key=True) name = Column(Unicode(255)) admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=False) diff --git a/project/models/event_reference.py b/project/models/event_reference.py index 48aac98..9194f24 100644 --- a/project/models/event_reference.py +++ b/project/models/event_reference.py @@ -6,11 +6,7 @@ from project.models.trackable_mixin import TrackableMixin class EventReference(db.Model, TrackableMixin): __tablename__ = "eventreference" - __table_args__ = ( - UniqueConstraint( - "event_id", "admin_unit_id", name="eventreference_event_id_admin_unit_id" - ), - ) + __table_args__ = (UniqueConstraint("event_id", "admin_unit_id"),) id = Column(Integer(), primary_key=True) event_id = db.Column(db.Integer, db.ForeignKey("event.id"), nullable=False) admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=False) diff --git a/project/models/event_reference_request.py b/project/models/event_reference_request.py index e0e9d4c..8df90b8 100644 --- a/project/models/event_reference_request.py +++ b/project/models/event_reference_request.py @@ -27,7 +27,6 @@ class EventReferenceRequest(db.Model, TrackableMixin): UniqueConstraint( "event_id", "admin_unit_id", - name="eventreferencerequest_event_id_admin_unit_id", ), ) id = Column(Integer(), primary_key=True) diff --git a/project/models/trackable_mixin.py b/project/models/trackable_mixin.py index d2323d8..9d51a50 100644 --- a/project/models/trackable_mixin.py +++ b/project/models/trackable_mixin.py @@ -1,7 +1,7 @@ import datetime from sqlalchemy import Column, DateTime, ForeignKey -from sqlalchemy.orm import declared_attr, deferred, relationship +from sqlalchemy.orm import backref, declared_attr, deferred, relationship from project.models.functions import _current_user_id_or_none @@ -29,7 +29,7 @@ class TrackableMixin(object): return deferred( Column( "created_by_id", - ForeignKey("user.id"), + ForeignKey("user.id", ondelete="SET NULL"), default=_current_user_id_or_none, ), group="trackable", @@ -41,6 +41,7 @@ class TrackableMixin(object): "User", primaryjoin="User.id == %s.created_by_id" % cls.__name__, remote_side="User.id", + backref=backref("created_%s" % cls.__tablename__, lazy=True), ) @declared_attr @@ -48,7 +49,7 @@ class TrackableMixin(object): return deferred( Column( "updated_by_id", - ForeignKey("user.id"), + ForeignKey("user.id", ondelete="SET NULL"), default=_current_user_id_or_none, onupdate=_current_user_id_or_none, ), @@ -61,4 +62,5 @@ class TrackableMixin(object): "User", primaryjoin="User.id == %s.updated_by_id" % cls.__name__, remote_side="User.id", + backref=backref("updated_%s" % cls.__tablename__, lazy=True), ) diff --git a/project/models/user.py b/project/models/user.py index dc69da0..1158d7d 100644 --- a/project/models/user.py +++ b/project/models/user.py @@ -60,6 +60,8 @@ class User(db.Model, UserMixin): ) ) created_at = Column(DateTime, default=datetime.datetime.utcnow) + created_at = deferred(Column(DateTime, default=datetime.datetime.utcnow)) + deletion_requested_at = deferred(Column(DateTime, nullable=True)) def get_user_id(self): return self.id diff --git a/project/services/user.py b/project/services/user.py index 841c25a..9d6e5ca 100644 --- a/project/services/user.py +++ b/project/services/user.py @@ -1,6 +1,8 @@ +import datetime + from flask_security import hash_password -from project import user_datastore +from project import db, user_datastore from project.models import Event, Role, User, UserFavoriteEvents @@ -99,3 +101,13 @@ def remove_favorite_event(user_id: int, event_id: int): db.session.delete(favorite) return True + + +def get_users_with_due_delete_request(): + due = datetime.datetime.utcnow() - datetime.timedelta(days=3) + return User.query.filter(User.deletion_requested_at < due).all() + + +def delete_user(user): + user_datastore.delete_user(user) + db.session.commit() diff --git a/project/templates/admin/users.html b/project/templates/admin/users.html index d14f912..db4f6c8 100644 --- a/project/templates/admin/users.html +++ b/project/templates/admin/users.html @@ -19,6 +19,7 @@ {{ _('Email') }} created_at confirmed_at + deletion_requested_at @@ -28,6 +29,7 @@ {{ user.email }} {% if user.created_at %}{{ user.created_at | dateformat }}{% endif %} {% if user.confirmed_at %}{{ user.confirmed_at | dateformat }}{% endif %} + {% if user.deletion_requested_at %}{{ user.deletion_requested_at | dateformat }}{% endif %} {{ _('Edit') }} {{ _('Delete') }} diff --git a/project/templates/email/organization_deletion_requested_notice.html b/project/templates/email/organization_deletion_requested_notice.html index b6a2155..1bcd61e 100644 --- a/project/templates/email/organization_deletion_requested_notice.html +++ b/project/templates/email/organization_deletion_requested_notice.html @@ -2,5 +2,5 @@ {% from "_macros.html" import render_email_button %} {% block content %}

{{ _('%(admin_unit_name)s is scheduled for deletion.', admin_unit_name=admin_unit.name) }}

-{{ render_email_button(url_for('admin_unit_cancel_deletion', id=admin_unit.id, _external=True), _('Click here to view the invitation')) }} +{{ render_email_button(url_for('admin_unit_cancel_deletion', id=admin_unit.id, _external=True), _('Click here below to cancel the deletion')) }} {% endblock %} \ No newline at end of file diff --git a/project/templates/email/user_deletion_requested_notice.html b/project/templates/email/user_deletion_requested_notice.html new file mode 100644 index 0000000..175abc3 --- /dev/null +++ b/project/templates/email/user_deletion_requested_notice.html @@ -0,0 +1,6 @@ +{% extends "email/layout.html" %} +{% from "_macros.html" import render_email_button %} +{% block content %} +

{{ _('%(user_email)s is scheduled for deletion.', user_email=user.email) }}

+{{ render_email_button(url_for('user_cancel_deletion', _external=True), _('Click the link below to cancel the deletion')) }} +{% endblock %} \ No newline at end of file diff --git a/project/templates/email/user_deletion_requested_notice.txt b/project/templates/email/user_deletion_requested_notice.txt new file mode 100644 index 0000000..7e36020 --- /dev/null +++ b/project/templates/email/user_deletion_requested_notice.txt @@ -0,0 +1,3 @@ +{{ _('%(user_email)s is scheduled for deletion.', user_email=user.email) }} +{{ _('Click the link below to cancel the deletion') }} +{{ url_for('user_cancel_deletion', _external=True) }} diff --git a/project/templates/profile.html b/project/templates/profile.html index c864fcb..cc3dafb 100644 --- a/project/templates/profile.html +++ b/project/templates/profile.html @@ -7,6 +7,13 @@

{{ current_user.email }}

+{% if current_user.deletion_requested_at %} + +{% endif %} +

{{ _('Profile') }}

@@ -14,6 +21,12 @@ {{ _fsdomain('Change password') }} + {% if not current_user.deletion_requested_at %} + + {{ _('Delete account') }} + + + {% endif %}

{{ _('Settings') }}

diff --git a/project/templates/user/cancel_deletion.html b/project/templates/user/cancel_deletion.html new file mode 100644 index 0000000..cd20b87 --- /dev/null +++ b/project/templates/user/cancel_deletion.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} +{% from "_macros.html" import render_field_with_errors, render_field %} + +{% block content %} + +

{{ _('Cancel deletion') }} "{{ current_user.email }}"

+ +
+ {{ form.hidden_tag() }} + +
+
+ {{ _('User') }} +
+
+ {{ render_field_with_errors(form.email) }} +
+
+ + {{ render_field(form.submit) }} + +
+ +{% endblock %} diff --git a/project/templates/user/request_deletion.html b/project/templates/user/request_deletion.html new file mode 100644 index 0000000..18286a1 --- /dev/null +++ b/project/templates/user/request_deletion.html @@ -0,0 +1,26 @@ +{% extends "layout.html" %} +{% from "_macros.html" import render_field_with_errors, render_field %} + +{% block content %} + +

{{ _('Delete account') }} "{{ current_user.email }}"

+ +

{{ _('The account is not deleted immediately. After a period of time, the account will be deleted. Until then, the deletion can be canceled.') }}

+ +
+ {{ form.hidden_tag() }} + +
+
+ {{ _('User') }} +
+
+ {{ render_field_with_errors(form.email) }} +
+
+ + {{ render_field(form.submit) }} + +
+ +{% endblock %} diff --git a/project/translations/de/LC_MESSAGES/messages.mo b/project/translations/de/LC_MESSAGES/messages.mo index 0ee23a4..a047f11 100644 Binary files a/project/translations/de/LC_MESSAGES/messages.mo and b/project/translations/de/LC_MESSAGES/messages.mo differ diff --git a/project/translations/de/LC_MESSAGES/messages.po b/project/translations/de/LC_MESSAGES/messages.po index c0f2770..403f51a 100644 --- a/project/translations/de/LC_MESSAGES/messages.po +++ b/project/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-04-27 15:10+0200\n" +"POT-Creation-Date: 2023-05-01 18:11+0200\n" "PO-Revision-Date: 2020-06-07 18:51+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -195,7 +195,7 @@ msgid "message" msgstr "message" #: project/api/organization/resources.py:401 -#: project/views/admin_unit_member_invitation.py:85 +#: project/views/admin_unit_member_invitation.py:89 msgid "You have received an invitation" msgstr "Du hast eine Einladung erhalten" @@ -212,7 +212,7 @@ msgstr "Impressum" #: project/forms/admin.py:13 project/templates/_macros.html:1473 #: project/templates/layout.html:311 #: project/templates/widget/event_suggestion/create.html:204 -#: project/views/admin_unit.py:82 project/views/root.py:71 +#: project/views/admin_unit.py:83 project/views/root.py:71 msgid "Contact" msgstr "Kontakt" @@ -248,6 +248,7 @@ msgstr "Nutzer löschen" #: project/forms/admin_unit_member.py:11 project/forms/admin_unit_member.py:23 #: project/forms/admin_unit_member.py:28 project/forms/event.py:107 #: project/forms/event_suggestion.py:38 project/forms/organizer.py:27 +#: project/forms/user.py:18 project/forms/user.py:23 #: project/templates/_macros.html:237 project/templates/_macros.html:1569 #: project/templates/admin/admin.html:27 project/templates/admin/email.html:4 #: project/templates/admin/email.html:66 project/templates/admin/users.html:19 @@ -494,14 +495,15 @@ msgstr "Hauptfarbe" msgid "Link Color" msgstr "Linkfarbe" -#: project/forms/admin_unit.py:133 +#: project/forms/admin_unit.py:133 project/forms/user.py:17 msgid "Request deletion" msgstr "Löschung beantragen" -#: project/forms/admin_unit.py:138 +#: project/forms/admin_unit.py:138 project/forms/user.py:22 #: project/templates/admin_unit/cancel_deletion.html:6 #: project/templates/admin_unit/update.html:26 -#: project/templates/manage/events.html:49 +#: project/templates/manage/events.html:49 project/templates/profile.html:13 +#: project/templates/user/cancel_deletion.html:6 msgid "Cancel deletion" msgstr "Löschen abbrechen" @@ -1381,7 +1383,7 @@ msgstr "Merkzettel" #: project/templates/_macros.html:590 project/templates/_macros.html:633 #: project/templates/_macros.html:765 #: project/templates/admin/admin_units.html:36 -#: project/templates/admin/users.html:32 +#: project/templates/admin/users.html:34 #: project/templates/manage/events.html:116 #: project/templates/manage/members.html:35 #: project/templates/manage/organizers.html:33 @@ -1596,7 +1598,7 @@ msgstr "Planung" #: project/templates/oauth2_client/list.html:10 #: project/templates/oauth2_client/read.html:10 #: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4 -#: project/templates/profile.html:10 +#: project/templates/profile.html:17 msgid "Profile" msgstr "Profil" @@ -1698,7 +1700,7 @@ msgstr "Organisationseinladungen" #: project/templates/admin_unit/update.html:6 #: project/templates/admin_unit/update.html:30 #: project/templates/layout.html:259 project/templates/manage/widgets.html:11 -#: project/templates/manage/widgets.html:15 project/templates/profile.html:19 +#: project/templates/manage/widgets.html:15 project/templates/profile.html:32 msgid "Settings" msgstr "Einstellungen" @@ -1724,24 +1726,34 @@ msgstr "Organisation wechseln" #: project/templates/developer/read.html:4 #: project/templates/developer/read.html:8 project/templates/layout.html:319 -#: project/templates/profile.html:33 +#: project/templates/profile.html:46 msgid "Developer" msgstr "Entwickler" -#: project/templates/profile.html:23 +#: project/templates/profile.html:12 project/views/admin_unit.py:89 +#: project/views/admin_unit_member_invitation.py:39 +msgid "Your account is scheduled for deletion." +msgstr "Dein Account ist zur Löschung vorgesehen." + +#: project/templates/profile.html:26 +#: project/templates/user/request_deletion.html:6 +msgid "Delete account" +msgstr "Account löschen" + +#: project/templates/profile.html:36 #: project/templates/user/notifications.html:4 #: project/templates/user/notifications.html:8 msgid "Notifications" msgstr "Benachrichtigungen" -#: project/templates/profile.html:27 +#: project/templates/profile.html:40 msgid "Applications" msgstr "Apps" #: project/templates/oauth2_client/list.html:4 #: project/templates/oauth2_client/list.html:11 #: project/templates/oauth2_client/read.html:11 -#: project/templates/profile.html:37 +#: project/templates/profile.html:50 msgid "OAuth2 clients" msgstr "OAuth2 Clients" @@ -1759,7 +1771,7 @@ msgid "View" msgstr "Anzeigen" #: project/templates/admin/admin_units.html:37 -#: project/templates/admin/users.html:33 +#: project/templates/admin/users.html:35 #: project/templates/manage/events.html:117 #: project/templates/manage/members.html:21 #: project/templates/manage/members.html:36 @@ -1771,6 +1783,8 @@ msgid "Delete" msgstr "Löschen" #: project/templates/admin/delete_user.html:13 +#: project/templates/user/cancel_deletion.html:13 +#: project/templates/user/request_deletion.html:15 msgid "User" msgstr "Nutzer" @@ -1815,7 +1829,9 @@ msgstr "Nutzer:in einladen" msgid "" "The organization is not deleted immediately. After a period of time, the " "organization will be deleted. Until then, the deletion can be canceled." -msgstr "Die Organisation wird nicht sofort gelöscht. Nach einer Frist wird die Organisation gelöscht. Bis dahin kann die Löschung abgebrochen werden." +msgstr "" +"Die Organisation wird nicht sofort gelöscht. Nach einer Frist wird die " +"Organisation gelöscht. Bis dahin kann die Löschung abgebrochen werden." #: project/templates/admin_unit/update.html:25 #: project/templates/manage/events.html:48 @@ -1857,7 +1873,6 @@ msgid "You have been invited to join %(admin_unit_name)s." msgstr "Du wurdest eingeladen, %(admin_unit_name)s beizutreten." #: project/templates/email/invitation_notice.html:5 -#: project/templates/email/organization_deletion_requested_notice.html:5 #: project/templates/email/organization_invitation_notice.html:5 msgid "Click here to view the invitation" msgstr "Klicke hier, um die Einladung anzunehmen." @@ -1880,6 +1895,10 @@ msgstr "Benachrichtigungseinstellungen" msgid "%(admin_unit_name)s is scheduled for deletion." msgstr "%(admin_unit_name)s ist zur Löschung vorgesehen." +#: project/templates/email/organization_deletion_requested_notice.html:5 +msgid "Click here below to cancel the deletion" +msgstr "Klicke hier, um das Löschen abzubrechen" + #: project/templates/email/organization_invitation_accepted_notice.html:4 #, python-format msgid "" @@ -1948,6 +1967,15 @@ msgstr "Das ist eine Test-Mail" msgid "Click here to open the site" msgstr "Klicke hier, um die Seite zu öffnen" +#: project/templates/email/user_deletion_requested_notice.html:4 +#, python-format +msgid "%(user_email)s is scheduled for deletion." +msgstr "%(user_email)s ist zur Löschung vorgesehen." + +#: project/templates/email/user_deletion_requested_notice.html:5 +msgid "Click the link below to cancel the deletion" +msgstr "Klicke den Link, um das Löschen abzubrechen" + #: project/templates/event/actions.html:5 #: project/templates/event/actions.html:22 msgid "Actions for event" @@ -2273,6 +2301,14 @@ msgstr "Du hast noch keinen Account? Kein Problem!" msgid "Register for free" msgstr "Kostenlos registrieren" +#: project/templates/user/request_deletion.html:8 +msgid "" +"The account is not deleted immediately. After a period of time, the " +"account will be deleted. Until then, the deletion can be canceled." +msgstr "" +"Dein Account wird nicht sofort gelöscht. Nach einer Frist wird dein " +"Account gelöscht. Bis dahin kann die Löschung abgebrochen werden." + #: project/templates/widget/event_date/list.html:5 msgid "Widget" msgstr "Widget" @@ -2297,8 +2333,8 @@ msgstr "Vorschau" msgid "Organization successfully updated" msgstr "Organisation erfolgreich aktualisiert" -#: project/views/admin.py:85 project/views/admin_unit.py:182 -#: project/views/admin_unit.py:215 +#: project/views/admin.py:85 project/views/admin_unit.py:187 +#: project/views/admin_unit.py:220 msgid "Entered name does not match organization name" msgstr "Der eingegebene Name entspricht nicht dem Namen der Organisation" @@ -2307,7 +2343,7 @@ msgid "Organization successfully deleted" msgstr "Organisation erfolgreich gelöscht" #: project/views/admin.py:113 project/views/manage.py:432 -#: project/views/user.py:28 +#: project/views/user.py:40 msgid "Settings successfully updated" msgstr "Einstellungen erfolgreich aktualisiert" @@ -2329,11 +2365,11 @@ msgstr "Nutzer erfolgreich aktualisiert" msgid "Entered email does not match user email" msgstr "Die eingegebene Email passt nicht zur Email des Nutzers" -#: project/views/admin.py:237 +#: project/views/admin.py:236 msgid "User successfully deleted" msgstr "Nutzer erfolgreich gelöscht" -#: project/views/admin_unit.py:78 +#: project/views/admin_unit.py:79 msgid "" "Organizations cannot currently be created. The project is in a closed " "test phase. If you are interested, you can contact us." @@ -2342,19 +2378,19 @@ msgstr "" " sich in einer geschlossenen Test-Phase. Bei Interesse kannst du uns " "kontaktieren." -#: project/views/admin_unit.py:128 +#: project/views/admin_unit.py:133 msgid "Organization successfully created" msgstr "Organisation erfolgreich erstellt" -#: project/views/admin_unit.py:158 +#: project/views/admin_unit.py:163 msgid "AdminUnit successfully updated" msgstr "Organisation erfolgreich aktualisiert" -#: project/views/admin_unit.py:245 +#: project/views/admin_unit.py:250 msgid "Organization invitation accepted" msgstr "Organisationseinladung akzeptiert" -#: project/views/admin_unit.py:259 +#: project/views/admin_unit.py:264 msgid "Organization deletion requested" msgstr "Löschung der Organisation beantragt" @@ -2370,23 +2406,23 @@ msgstr "Die eingegebene Email passt nicht zur Email des Mitglieds" msgid "Member successfully deleted" msgstr "Mitglied erfolgreich gelöscht" -#: project/views/admin_unit_member_invitation.py:38 +#: project/views/admin_unit_member_invitation.py:42 msgid "Invitation successfully accepted" msgstr "Einladung erfolgreich akzeptiert" -#: project/views/admin_unit_member_invitation.py:45 +#: project/views/admin_unit_member_invitation.py:49 msgid "Invitation successfully declined" msgstr "Einladung erfolgreich abgelehnt" -#: project/views/admin_unit_member_invitation.py:90 +#: project/views/admin_unit_member_invitation.py:94 msgid "Invitation successfully sent" msgstr "Einladung erfolgreich gesendet" -#: project/views/admin_unit_member_invitation.py:113 +#: project/views/admin_unit_member_invitation.py:117 msgid "Entered email does not match invitation email" msgstr "Die eingegebene Email passt nicht zur Email der Einladung" -#: project/views/admin_unit_member_invitation.py:118 +#: project/views/admin_unit_member_invitation.py:122 msgid "Invitation successfully deleted" msgstr "Einladung erfolgreich gelöscht" @@ -2545,6 +2581,14 @@ msgstr "" "Ob alle zukünftigen Empfehlungsanfragen von %(admin_unit_name)s " "automatisch verifiziert werden sollen." +#: project/views/user.py:84 project/views/user.py:111 +msgid "Entered email does not match your email" +msgstr "Die eingegebene Email entspricht nicht deiner Email" + +#: project/views/user.py:130 +msgid "User deletion requested" +msgstr "Löschung des Nutzers beantragt" + #: project/views/utils.py:71 msgid "" "An entry with the entered values ​​already exists. Duplicate entries are " diff --git a/project/translations/en/LC_MESSAGES/messages.mo b/project/translations/en/LC_MESSAGES/messages.mo index a431fc4..f4b8290 100644 Binary files a/project/translations/en/LC_MESSAGES/messages.mo and b/project/translations/en/LC_MESSAGES/messages.mo differ diff --git a/project/translations/en/LC_MESSAGES/messages.po b/project/translations/en/LC_MESSAGES/messages.po index 6ec5d84..a077201 100644 --- a/project/translations/en/LC_MESSAGES/messages.po +++ b/project/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-04-27 15:10+0200\n" +"POT-Creation-Date: 2023-05-01 18:11+0200\n" "PO-Revision-Date: 2021-04-30 15:04+0200\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -195,7 +195,7 @@ msgid "message" msgstr "" #: project/api/organization/resources.py:401 -#: project/views/admin_unit_member_invitation.py:85 +#: project/views/admin_unit_member_invitation.py:89 msgid "You have received an invitation" msgstr "" @@ -212,7 +212,7 @@ msgstr "" #: project/forms/admin.py:13 project/templates/_macros.html:1473 #: project/templates/layout.html:311 #: project/templates/widget/event_suggestion/create.html:204 -#: project/views/admin_unit.py:82 project/views/root.py:71 +#: project/views/admin_unit.py:83 project/views/root.py:71 msgid "Contact" msgstr "" @@ -248,6 +248,7 @@ msgstr "" #: project/forms/admin_unit_member.py:11 project/forms/admin_unit_member.py:23 #: project/forms/admin_unit_member.py:28 project/forms/event.py:107 #: project/forms/event_suggestion.py:38 project/forms/organizer.py:27 +#: project/forms/user.py:18 project/forms/user.py:23 #: project/templates/_macros.html:237 project/templates/_macros.html:1569 #: project/templates/admin/admin.html:27 project/templates/admin/email.html:4 #: project/templates/admin/email.html:66 project/templates/admin/users.html:19 @@ -475,14 +476,15 @@ msgstr "" msgid "Link Color" msgstr "" -#: project/forms/admin_unit.py:133 +#: project/forms/admin_unit.py:133 project/forms/user.py:17 msgid "Request deletion" msgstr "" -#: project/forms/admin_unit.py:138 +#: project/forms/admin_unit.py:138 project/forms/user.py:22 #: project/templates/admin_unit/cancel_deletion.html:6 #: project/templates/admin_unit/update.html:26 -#: project/templates/manage/events.html:49 +#: project/templates/manage/events.html:49 project/templates/profile.html:13 +#: project/templates/user/cancel_deletion.html:6 msgid "Cancel deletion" msgstr "" @@ -1332,7 +1334,7 @@ msgstr "" #: project/templates/_macros.html:590 project/templates/_macros.html:633 #: project/templates/_macros.html:765 #: project/templates/admin/admin_units.html:36 -#: project/templates/admin/users.html:32 +#: project/templates/admin/users.html:34 #: project/templates/manage/events.html:116 #: project/templates/manage/members.html:35 #: project/templates/manage/organizers.html:33 @@ -1547,7 +1549,7 @@ msgstr "" #: project/templates/oauth2_client/list.html:10 #: project/templates/oauth2_client/read.html:10 #: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4 -#: project/templates/profile.html:10 +#: project/templates/profile.html:17 msgid "Profile" msgstr "" @@ -1649,7 +1651,7 @@ msgstr "" #: project/templates/admin_unit/update.html:6 #: project/templates/admin_unit/update.html:30 #: project/templates/layout.html:259 project/templates/manage/widgets.html:11 -#: project/templates/manage/widgets.html:15 project/templates/profile.html:19 +#: project/templates/manage/widgets.html:15 project/templates/profile.html:32 msgid "Settings" msgstr "" @@ -1675,24 +1677,34 @@ msgstr "" #: project/templates/developer/read.html:4 #: project/templates/developer/read.html:8 project/templates/layout.html:319 -#: project/templates/profile.html:33 +#: project/templates/profile.html:46 msgid "Developer" msgstr "" -#: project/templates/profile.html:23 +#: project/templates/profile.html:12 project/views/admin_unit.py:89 +#: project/views/admin_unit_member_invitation.py:39 +msgid "Your account is scheduled for deletion." +msgstr "" + +#: project/templates/profile.html:26 +#: project/templates/user/request_deletion.html:6 +msgid "Delete account" +msgstr "" + +#: project/templates/profile.html:36 #: project/templates/user/notifications.html:4 #: project/templates/user/notifications.html:8 msgid "Notifications" msgstr "" -#: project/templates/profile.html:27 +#: project/templates/profile.html:40 msgid "Applications" msgstr "" #: project/templates/oauth2_client/list.html:4 #: project/templates/oauth2_client/list.html:11 #: project/templates/oauth2_client/read.html:11 -#: project/templates/profile.html:37 +#: project/templates/profile.html:50 msgid "OAuth2 clients" msgstr "" @@ -1710,7 +1722,7 @@ msgid "View" msgstr "" #: project/templates/admin/admin_units.html:37 -#: project/templates/admin/users.html:33 +#: project/templates/admin/users.html:35 #: project/templates/manage/events.html:117 #: project/templates/manage/members.html:21 #: project/templates/manage/members.html:36 @@ -1722,6 +1734,8 @@ msgid "Delete" msgstr "" #: project/templates/admin/delete_user.html:13 +#: project/templates/user/cancel_deletion.html:13 +#: project/templates/user/request_deletion.html:15 msgid "User" msgstr "" @@ -1808,7 +1822,6 @@ msgid "You have been invited to join %(admin_unit_name)s." msgstr "" #: project/templates/email/invitation_notice.html:5 -#: project/templates/email/organization_deletion_requested_notice.html:5 #: project/templates/email/organization_invitation_notice.html:5 msgid "Click here to view the invitation" msgstr "" @@ -1831,6 +1844,10 @@ msgstr "" msgid "%(admin_unit_name)s is scheduled for deletion." msgstr "" +#: project/templates/email/organization_deletion_requested_notice.html:5 +msgid "Click here below to cancel the deletion" +msgstr "" + #: project/templates/email/organization_invitation_accepted_notice.html:4 #, python-format msgid "" @@ -1897,6 +1914,15 @@ msgstr "" msgid "Click here to open the site" msgstr "" +#: project/templates/email/user_deletion_requested_notice.html:4 +#, python-format +msgid "%(user_email)s is scheduled for deletion." +msgstr "" + +#: project/templates/email/user_deletion_requested_notice.html:5 +msgid "Click the link below to cancel the deletion" +msgstr "" + #: project/templates/event/actions.html:5 #: project/templates/event/actions.html:22 msgid "Actions for event" @@ -2213,6 +2239,12 @@ msgstr "" msgid "Register for free" msgstr "" +#: project/templates/user/request_deletion.html:8 +msgid "" +"The account is not deleted immediately. After a period of time, the " +"account will be deleted. Until then, the deletion can be canceled." +msgstr "" + #: project/templates/widget/event_date/list.html:5 msgid "Widget" msgstr "" @@ -2237,8 +2269,8 @@ msgstr "" msgid "Organization successfully updated" msgstr "" -#: project/views/admin.py:85 project/views/admin_unit.py:182 -#: project/views/admin_unit.py:215 +#: project/views/admin.py:85 project/views/admin_unit.py:187 +#: project/views/admin_unit.py:220 msgid "Entered name does not match organization name" msgstr "" @@ -2247,7 +2279,7 @@ msgid "Organization successfully deleted" msgstr "" #: project/views/admin.py:113 project/views/manage.py:432 -#: project/views/user.py:28 +#: project/views/user.py:40 msgid "Settings successfully updated" msgstr "" @@ -2269,29 +2301,29 @@ msgstr "" msgid "Entered email does not match user email" msgstr "" -#: project/views/admin.py:237 +#: project/views/admin.py:236 msgid "User successfully deleted" msgstr "" -#: project/views/admin_unit.py:78 +#: project/views/admin_unit.py:79 msgid "" "Organizations cannot currently be created. The project is in a closed " "test phase. If you are interested, you can contact us." msgstr "" -#: project/views/admin_unit.py:128 +#: project/views/admin_unit.py:133 msgid "Organization successfully created" msgstr "" -#: project/views/admin_unit.py:158 +#: project/views/admin_unit.py:163 msgid "AdminUnit successfully updated" msgstr "" -#: project/views/admin_unit.py:245 +#: project/views/admin_unit.py:250 msgid "Organization invitation accepted" msgstr "" -#: project/views/admin_unit.py:259 +#: project/views/admin_unit.py:264 msgid "Organization deletion requested" msgstr "" @@ -2307,23 +2339,23 @@ msgstr "" msgid "Member successfully deleted" msgstr "" -#: project/views/admin_unit_member_invitation.py:38 +#: project/views/admin_unit_member_invitation.py:42 msgid "Invitation successfully accepted" msgstr "" -#: project/views/admin_unit_member_invitation.py:45 +#: project/views/admin_unit_member_invitation.py:49 msgid "Invitation successfully declined" msgstr "" -#: project/views/admin_unit_member_invitation.py:90 +#: project/views/admin_unit_member_invitation.py:94 msgid "Invitation successfully sent" msgstr "" -#: project/views/admin_unit_member_invitation.py:113 +#: project/views/admin_unit_member_invitation.py:117 msgid "Entered email does not match invitation email" msgstr "" -#: project/views/admin_unit_member_invitation.py:118 +#: project/views/admin_unit_member_invitation.py:122 msgid "Invitation successfully deleted" msgstr "" @@ -2478,6 +2510,14 @@ msgid "" "verified automatically." msgstr "" +#: project/views/user.py:84 project/views/user.py:111 +msgid "Entered email does not match your email" +msgstr "" + +#: project/views/user.py:130 +msgid "User deletion requested" +msgstr "" + #: project/views/utils.py:71 msgid "" "An entry with the entered values ​​already exists. Duplicate entries are " diff --git a/project/views/admin.py b/project/views/admin.py index d9cf1c5..fde5905 100644 --- a/project/views/admin.py +++ b/project/views/admin.py @@ -5,7 +5,7 @@ from flask_security import roles_required from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.sql import func -from project import app, db, user_datastore +from project import app, db from project.base_tasks import send_mail_task from project.forms.admin import ( AdminNewsletterForm, @@ -19,7 +19,7 @@ from project.forms.admin import ( from project.models import AdminUnit, Role, User from project.services.admin import upsert_settings from project.services.admin_unit import delete_admin_unit -from project.services.user import set_roles_for_user +from project.services.user import delete_user, set_roles_for_user from project.views.utils import ( flash_errors, get_celery_poll_group_result, @@ -232,8 +232,7 @@ def admin_user_delete(id): flash(gettext("Entered email does not match user email"), "danger") else: try: - user_datastore.delete_user(user) - db.session.commit() + delete_user(user) flash(gettext("User successfully deleted"), "success") return redirect(url_for("admin_users")) except SQLAlchemyError as e: diff --git a/project/views/admin_unit.py b/project/views/admin_unit.py index 0709adf..980d31e 100644 --- a/project/views/admin_unit.py +++ b/project/views/admin_unit.py @@ -73,16 +73,21 @@ def admin_unit_create(): if not strings_are_equal_ignoring_case(invitation.email, current_user.email): return permission_missing(url_for("manage_admin_units")) - if not invitation and not can_create_admin_unit(): - flash_message( - gettext( - "Organizations cannot currently be created. The project is in a closed test phase. If you are interested, you can contact us." - ), - url_for("contact"), - gettext("Contact"), - "danger", - ) - return redirect(url_for("manage_admin_units")) + if not invitation: + if not can_create_admin_unit(): + flash_message( + gettext( + "Organizations cannot currently be created. The project is in a closed test phase. If you are interested, you can contact us." + ), + url_for("contact"), + gettext("Contact"), + "danger", + ) + return redirect(url_for("manage_admin_units")) + + if current_user.deletion_requested_at: # pragma: no cover + flash(gettext("Your account is scheduled for deletion."), "danger") + return redirect(url_for("profile")) form = CreateAdminUnitForm() diff --git a/project/views/admin_unit_member_invitation.py b/project/views/admin_unit_member_invitation.py index a43ae0e..25ec935 100644 --- a/project/views/admin_unit_member_invitation.py +++ b/project/views/admin_unit_member_invitation.py @@ -35,6 +35,10 @@ def admin_unit_member_invitation(id): if form.validate_on_submit(): try: if form.accept.data: + if current_user.deletion_requested_at: # pragma: no cover + flash(gettext("Your account is scheduled for deletion."), "danger") + return redirect(url_for("profile")) + message = gettext("Invitation successfully accepted") roles = invitation.roles.split(",") add_user_to_admin_unit_with_roles( diff --git a/project/views/user.py b/project/views/user.py index 27638f0..d85aae7 100644 --- a/project/views/user.py +++ b/project/views/user.py @@ -1,12 +1,24 @@ +import datetime + from flask import flash, redirect, render_template, url_for from flask_babel import gettext from flask_security import auth_required, current_user from sqlalchemy.exc import SQLAlchemyError from project import app, db -from project.forms.user import NotificationForm +from project.forms.user import ( + CancelUserDeletionForm, + NotificationForm, + RequestUserDeletionForm, +) from project.models import AdminUnitInvitation, User -from project.views.utils import get_invitation_access_result, handleSqlError +from project.views.utils import ( + flash_errors, + get_invitation_access_result, + handleSqlError, + non_match_for_deletion, + send_mail, +) @app.route("/profile") @@ -56,3 +68,66 @@ def user_organization_invitations(path=None): @auth_required() def user_favorite_events(): return render_template("user/favorite_events.html") + + +@app.route("/user/request-deletion", methods=("GET", "POST")) +@auth_required() +def user_request_deletion(): + if current_user.deletion_requested_at: # pragma: no cover + return redirect(url_for("user_cancel_deletion")) + + form = None + form = RequestUserDeletionForm() + + if form.validate_on_submit(): + if non_match_for_deletion(form.email.data, current_user.email): + flash(gettext("Entered email does not match your email"), "danger") + else: + current_user.deletion_requested_at = datetime.datetime.utcnow() + + try: + db.session.commit() + send_user_deletion_requested_mail(current_user) + return redirect(url_for("profile")) + except SQLAlchemyError as e: + db.session.rollback() + flash(handleSqlError(e), "danger") + else: + flash_errors(form) + + return render_template("user/request_deletion.html", form=form) + + +@app.route("/user/cancel-deletion", methods=("GET", "POST")) +@auth_required() +def user_cancel_deletion(): + if not current_user.deletion_requested_at: # pragma: no cover + return redirect(url_for("user_request_deletion")) + + form = CancelUserDeletionForm() + + if form.validate_on_submit(): + if non_match_for_deletion(form.email.data, current_user.email): + flash(gettext("Entered email does not match your email"), "danger") + else: + current_user.deletion_requested_at = None + + try: + db.session.commit() + return redirect(url_for("profile")) + except SQLAlchemyError as e: + db.session.rollback() + flash(handleSqlError(e), "danger") + else: + flash_errors(form) + + return render_template("user/cancel_deletion.html", form=form) + + +def send_user_deletion_requested_mail(user): + send_mail( + user.email, + gettext("User deletion requested"), + "user_deletion_requested_notice", + user=user, + ) diff --git a/project/views/utils.py b/project/views/utils.py index 3be861d..f5fe6d6 100644 --- a/project/views/utils.py +++ b/project/views/utils.py @@ -170,7 +170,7 @@ def send_mail_message(msg): app.logger.info(msg.body) return - mail.send(msg) + mail.send(msg) # pragma: no cover def non_match_for_deletion(str1: str, str2: str) -> bool: diff --git a/tests/api/test___init__.py b/tests/api/test___init__.py index b5096f6..3cefa56 100644 --- a/tests/api/test___init__.py +++ b/tests/api/test___init__.py @@ -1,9 +1,8 @@ import pytest -from project.api import RestApi - def test_handle_error_unique(app): + from project.api import RestApi from project.utils import make_unique_violation error = make_unique_violation() @@ -17,6 +16,7 @@ def test_handle_error_unique(app): def test_handle_error_checkViolation(app): + from project.api import RestApi from project.utils import make_check_violation error = make_check_violation() @@ -30,6 +30,7 @@ def test_handle_error_checkViolation(app): def test_handle_error_integrity(app): + from project.api import RestApi from project.utils import make_integrity_error error = make_integrity_error("custom") @@ -45,6 +46,8 @@ def test_handle_error_integrity(app): def test_handle_error_httpException(app): from werkzeug.exceptions import InternalServerError + from project.api import RestApi + error = InternalServerError() with app.app_context(): @@ -58,6 +61,8 @@ def test_handle_error_unprocessableEntity(app): from marshmallow import ValidationError from werkzeug.exceptions import UnprocessableEntity + from project.api import RestApi + args = {"name": ["Required"]} validation_error = ValidationError(args) @@ -76,6 +81,8 @@ def test_handle_error_unprocessableEntity(app): def test_handle_error_validationError(app): from marshmallow import ValidationError + from project.api import RestApi + args = {"name": ["Required"]} validation_error = ValidationError(args) @@ -90,6 +97,7 @@ def test_handle_error_validationError(app): def test_handle_error_unspecificRaises(app): error = Exception() + from project.api import RestApi with app.app_context(): app.config["PROPAGATE_EXCEPTIONS"] = False diff --git a/tests/api/test_event.py b/tests/api/test_event.py index 267a260..6af16c4 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -2,8 +2,6 @@ import base64 import pytest -from project.models import PublicStatus - def test_read(client, app, db, seeder, utils): user_id, admin_unit_id = seeder.setup_base() @@ -225,6 +223,7 @@ def test_put(client, seeder, utils, app, db, mocker, variant): EventAttendanceMode, EventStatus, EventTargetGroupOrigin, + PublicStatus, ) event = db.session.get(Event, event_id) diff --git a/tests/seeder.py b/tests/seeder.py index 9dfefc8..bd4d0df 100644 --- a/tests/seeder.py +++ b/tests/seeder.py @@ -333,6 +333,7 @@ class Seeder(object): description="Beschreibung", tags="", place_id=None, + **kwargs ): from project.models import ( Event, @@ -344,6 +345,7 @@ class Seeder(object): with self._app.app_context(): event = Event() + event.__dict__.update(kwargs) event.admin_unit_id = admin_unit_id event.categories = [upsert_event_category("Other")] event.name = name diff --git a/tests/services/test_user.py b/tests/services/test_user.py index d0da77f..5128ac8 100644 --- a/tests/services/test_user.py +++ b/tests/services/test_user.py @@ -40,3 +40,22 @@ def test_remove_favorite_event(client, seeder, utils, app): assert remove_favorite_event(user_id, event_id) assert remove_favorite_event(user_id, event_id) is False assert has_favorite_event(user_id, event_id) is False + + +def test_get_users_with_due_delete_request(client, seeder, db, utils, app): + user_id, admin_unit_id = seeder.setup_base() + + with app.app_context(): + import datetime + + from project.models import User + from project.services.user import get_users_with_due_delete_request + + user = db.session.get(User, user_id) + user.deletion_requested_at = datetime.datetime.utcnow() - datetime.timedelta( + days=4 + ) + db.session.commit() + + due_users = get_users_with_due_delete_request() + assert len(due_users) == 1 diff --git a/tests/test___init__.py b/tests/test___init__.py index 941aa2f..b1b0704 100644 --- a/tests/test___init__.py +++ b/tests/test___init__.py @@ -40,6 +40,25 @@ END $$; conn.execute(sqlalchemy.text(sql).execution_options(autocommit=True)) +def migrate_with_result(app): + from alembic import command + + config = app.extensions["migrate"].migrate.get_config( + None, opts=["autogenerate"], x_arg=None + ) + return command.revision( + config, + None, + autogenerate=True, + sql=False, + head="head", + splice=False, + branch_label=None, + version_path=None, + rev_id=None, + ) + + def test_migrations(app, seeder): from flask_migrate import downgrade, upgrade @@ -49,6 +68,8 @@ def test_migrations(app, seeder): with app.app_context(): drop_db(db) upgrade() + migrate_result = migrate_with_result(app) + assert not migrate_result create_initial_data() user_id, admin_unit_id = seeder.setup_base() seeder.upsert_default_event_place(admin_unit_id) diff --git a/tests/test_models.py b/tests/test_models.py index 151af84..e3f3f29 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,6 @@ import pytest -from project.models import EventDateDefinition +from tests.seeder import Seeder def test_location_update_coordinate(client, app, db): @@ -150,7 +150,7 @@ def test_event_date_defintion_deletion(client, app, db, seeder): event_id = seeder.create_event(admin_unit_id) with app.app_context(): - from project.models import Event + from project.models import Event, EventDateDefinition # Initial eine Definition event = db.session.get(Event, event_id) @@ -568,3 +568,26 @@ def test_delete_admin_unit(client, app, db, seeder): location = db.session.get(Location, location_id) assert admin_unit is location + + +def test_delete_user(client, app, db, seeder: Seeder): + user_id, admin_unit_id = seeder.setup_base(log_in=False) + event_id = seeder.create_event(admin_unit_id, created_by_id=user_id) + + with app.app_context(): + from project.models import AdminUnit, AdminUnitMember, Event, User + from project.services.user import delete_user + + user = db.session.get(User, user_id) + member_id = user.adminunitmembers[0].id + delete_user(user) + + # User and membership should be gone + assert db.session.get(User, user_id) is None + assert db.session.get(AdminUnitMember, member_id) is None + + # Admin unit and event should still be there + assert db.session.get(AdminUnit, admin_unit_id) is not None + event = db.session.get(Event, event_id) + event is not None + event.created_by_id is None diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py index 3fb6f61..f24731c 100644 --- a/tests/views/test_auth.py +++ b/tests/views/test_auth.py @@ -1,7 +1,6 @@ -from project.services.user import find_user_by_email - - def test_register(client, app, utils): + from project.services.user import find_user_by_email + utils.register("test@test.de", "MeinPasswortIstDasBeste") with app.app_context(): diff --git a/tests/views/test_image.py b/tests/views/test_image.py index 12e2901..12effcf 100644 --- a/tests/views/test_image.py +++ b/tests/views/test_image.py @@ -2,11 +2,11 @@ import shutil import pytest -from project import img_path - @pytest.mark.parametrize("size", [None, 100]) def test_read(app, seeder, utils, size): + from project import img_path + user_id, admin_unit_id = seeder.setup_base() image_id = seeder.upsert_default_image() diff --git a/tests/views/test_root.py b/tests/views/test_root.py index 1e9a239..ba132aa 100644 --- a/tests/views/test_root.py +++ b/tests/views/test_root.py @@ -1,7 +1,5 @@ import os -from project import dump_path - def test_home(client, seeder, utils): url = utils.get_url("home") @@ -69,6 +67,8 @@ def test_privacy(app, db, utils): def test_developer(client, seeder, utils): + from project import dump_path + file_name = "all.zip" all_path = os.path.join(dump_path, file_name) diff --git a/tests/views/test_user.py b/tests/views/test_user.py index 6c15446..73c0b9a 100644 --- a/tests/views/test_user.py +++ b/tests/views/test_user.py @@ -1,5 +1,7 @@ import pytest +from tests.seeder import Seeder + def test_profile(client, seeder, utils): user_id, admin_unit_id = seeder.setup_base() @@ -110,3 +112,102 @@ def test_login_flash(client, seeder, utils): utils.assert_response_error_message( response, "Beachte, dass du deine E-Mail-Adresse bestätigen muss." ) + + +@pytest.mark.parametrize("db_error", [True, False]) +@pytest.mark.parametrize("non_match", [True, False]) +def test_user_request_deletion( + client, seeder: Seeder, utils, app, db, mocker, db_error, non_match +): + user_id, admin_unit_id = seeder.setup_base() + + url = utils.get_url("user_request_deletion", id=user_id) + response = utils.get_ok(url) + + if db_error: + utils.mock_db_commit(mocker) + + form_email = "test@test.de" + + if non_match: + form_email = "wrong" + + response = utils.post_form( + url, + response, + { + "email": form_email, + }, + ) + + if non_match: + utils.assert_response_error_message( + response, "Die eingegebene Email entspricht nicht deiner Email" + ) + return + + if db_error: + utils.assert_response_db_error(response) + return + + utils.assert_response_redirect(response, "profile") + + with app.app_context(): + from project.models import User + + user = db.session.get(User, user_id) + assert user.deletion_requested_at is not None + + +@pytest.mark.parametrize("db_error", [True, False]) +@pytest.mark.parametrize("non_match", [True, False]) +def test_user_cancel_deletion( + client, seeder, utils, app, db, mocker, db_error, non_match +): + user_id, admin_unit_id = seeder.setup_base() + + with app.app_context(): + import datetime + + from project.models import User + + user = db.session.get(User, user_id) + user.deletion_requested_at = datetime.datetime.utcnow() + db.session.commit() + + url = utils.get_url("user_cancel_deletion", id=user_id) + response = utils.get_ok(url) + + if db_error: + utils.mock_db_commit(mocker) + + form_email = "test@test.de" + + if non_match: + form_email = "wrong" + + response = utils.post_form( + url, + response, + { + "email": form_email, + }, + ) + + if non_match: + utils.assert_response_error_message( + response, "Die eingegebene Email entspricht nicht deiner Email" + ) + return + + if db_error: + utils.assert_response_db_error(response) + return + + utils.assert_response_redirect(response, "profile") + + with app.app_context(): + from project.models import User + + user = db.session.get(User, user_id) + assert user.deletion_requested_at is None