mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-31 03:53:50 +00:00 
			
		
		
		
	realm: Add option to schedule data deletion while deactivating.
Introduce a feature to schedule realm data deletion time during realm deactivation. This includes a server-level setting to configure the minimum and maximum allowed deletion days. Co-authored-by: Ujjawal Modi <umodi2003@gmail.com> Co-authored-by: Lauryn Menard <lauryn@zulip.com> Fixes #24677.
This commit is contained in:
		| @@ -20,6 +20,17 @@ format used by the Zulip server that they are interacting with. | |||||||
|  |  | ||||||
| ## Changes in Zulip 10.0 | ## Changes in Zulip 10.0 | ||||||
|  |  | ||||||
|  | **Feature level 332** | ||||||
|  |  | ||||||
|  | * [`POST /register`](/api/register-queue): Added | ||||||
|  |   `server_min_deactivated_realm_deletion_days` and | ||||||
|  |   `server_max_deactivated_realm_deletion_days` fields for the permitted | ||||||
|  |   number of days before full data deletion of a deactivated organization | ||||||
|  |   on the server. | ||||||
|  | * `POST /realm/deactivate`: Added `deletion_delay_days` parameter to | ||||||
|  |   support setting when a full data deletion of the deactivated | ||||||
|  |   organization may be done. | ||||||
|  |  | ||||||
| **Feature level 331** | **Feature level 331** | ||||||
|  |  | ||||||
| * [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events), | * [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events), | ||||||
|   | |||||||
| @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" | |||||||
| # new level means in api_docs/changelog.md, as well as "**Changes**" | # new level means in api_docs/changelog.md, as well as "**Changes**" | ||||||
| # entries in the endpoint's documentation in `zulip.yaml`. | # entries in the endpoint's documentation in `zulip.yaml`. | ||||||
|  |  | ||||||
| API_FEATURE_LEVEL = 331  # Last bumped for realm-level setting, `moderation_request_channel`. | API_FEATURE_LEVEL = 332  # Last bumped for data deletion of deactivated realms. | ||||||
|  |  | ||||||
| # Bump the minor PROVISION_VERSION to indicate that folks should provision | # Bump the minor PROVISION_VERSION to indicate that folks should provision | ||||||
| # only when going from an old version of the code to a newer version. Bump | # only when going from an old version of the code to a newer version. Bump | ||||||
|   | |||||||
| @@ -512,6 +512,53 @@ export const custom_time_unit_values = { | |||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const realm_deletion_in_values = { | ||||||
|  |     immediately: { | ||||||
|  |         value: 0, | ||||||
|  |         description: $t({defaultMessage: "Immediately"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     fourteen_days: { | ||||||
|  |         value: 14 * 24 * 60, | ||||||
|  |         description: $t({defaultMessage: "14 days"}), | ||||||
|  |         default: true, | ||||||
|  |     }, | ||||||
|  |     thirty_days: { | ||||||
|  |         value: 30 * 24 * 60, | ||||||
|  |         description: $t({defaultMessage: "30 days"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     ninty_days: { | ||||||
|  |         value: 90 * 24 * 60, | ||||||
|  |         description: $t({defaultMessage: "90 days"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     one_year: { | ||||||
|  |         value: 365 * 24 * 60, | ||||||
|  |         description: $t({defaultMessage: "1 year"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     two_years: { | ||||||
|  |         value: 365 * 24 * 60 * 2, | ||||||
|  |         description: $t({defaultMessage: "2 years"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     never: { | ||||||
|  |         // Ideally we'd just store `null`, not the string `"null"`, but | ||||||
|  |         // .val() will read null back as `""`.  Custom logic in | ||||||
|  |         // do_deactivate_realm converts this back to `null` | ||||||
|  |         // before sending to the server. | ||||||
|  |         value: "null", | ||||||
|  |         description: $t({defaultMessage: "Never"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  |     custom: { | ||||||
|  |         value: "custom", | ||||||
|  |         description: $t({defaultMessage: "Custom"}), | ||||||
|  |         default: false, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  |  | ||||||
| const user_role_array = Object.values(user_role_values); | const user_role_array = Object.values(user_role_values); | ||||||
| export const user_role_map = new Map(user_role_array.map((role) => [role.code, role.description])); | export const user_role_map = new Map(user_role_array.map((role) => [role.code, role.description])); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import {add} from "date-fns"; | ||||||
| import $ from "jquery"; | import $ from "jquery"; | ||||||
| import assert from "minimalistic-assert"; | import assert from "minimalistic-assert"; | ||||||
| import {z} from "zod"; | import {z} from "zod"; | ||||||
| @@ -39,6 +40,7 @@ import {current_user, realm, realm_schema} from "./state_data.ts"; | |||||||
| import type {Realm} from "./state_data.ts"; | import type {Realm} from "./state_data.ts"; | ||||||
| import * as stream_settings_data from "./stream_settings_data.ts"; | import * as stream_settings_data from "./stream_settings_data.ts"; | ||||||
| import type {StreamSubscription} from "./sub_store.ts"; | import type {StreamSubscription} from "./sub_store.ts"; | ||||||
|  | import * as timerender from "./timerender.ts"; | ||||||
| import {group_setting_value_schema} from "./types.ts"; | import {group_setting_value_schema} from "./types.ts"; | ||||||
| import type {HTMLSelectOneElement} from "./types.ts"; | import type {HTMLSelectOneElement} from "./types.ts"; | ||||||
| import * as ui_report from "./ui_report.ts"; | import * as ui_report from "./ui_report.ts"; | ||||||
| @@ -754,24 +756,219 @@ export function deactivate_organization(e: JQuery.Event): void { | |||||||
|     e.stopPropagation(); |     e.stopPropagation(); | ||||||
|  |  | ||||||
|     function do_deactivate_realm(): void { |     function do_deactivate_realm(): void { | ||||||
|  |         const raw_delete_in = $<HTMLSelectOneElement>( | ||||||
|  |             "select:not([multiple])#delete-realm-data-in", | ||||||
|  |         ).val()!; | ||||||
|  |         let delete_in_days: number | null; | ||||||
|  |  | ||||||
|  |         // See settings_config.realm_deletion_in_values for why we do this conversion. | ||||||
|  |         if (raw_delete_in === "null") { | ||||||
|  |             delete_in_days = null; | ||||||
|  |         } else if (raw_delete_in === "custom") { | ||||||
|  |             const deletes_in_minutes = util.get_custom_time_in_minutes( | ||||||
|  |                 custom_deletion_time_unit, | ||||||
|  |                 custom_deletion_time_input, | ||||||
|  |             ); | ||||||
|  |             delete_in_days = deletes_in_minutes / (60 * 24); | ||||||
|  |         } else { | ||||||
|  |             const deletes_in_minutes = Number.parseFloat(raw_delete_in); | ||||||
|  |             delete_in_days = deletes_in_minutes / (60 * 24); | ||||||
|  |         } | ||||||
|  |         const data = { | ||||||
|  |             deletion_delay_days: JSON.stringify(delete_in_days), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|         channel.post({ |         channel.post({ | ||||||
|             url: "/json/realm/deactivate", |             url: "/json/realm/deactivate", | ||||||
|  |             data, | ||||||
|             error(xhr) { |             error(xhr) { | ||||||
|                 ui_report.error($t_html({defaultMessage: "Failed"}), xhr, $("#dialog_error")); |                 ui_report.error($t_html({defaultMessage: "Failed"}), xhr, $("#dialog_error")); | ||||||
|             }, |             }, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const html_body = render_settings_deactivate_realm_modal(); |     let custom_deletion_time_input = realm.server_min_deactivated_realm_deletion_days ?? 0; | ||||||
|  |     let custom_deletion_time_unit = settings_config.custom_time_unit_values.days.name; | ||||||
|  |  | ||||||
|  |     function delete_data_in_text(): string { | ||||||
|  |         const $delete_in = $<HTMLSelectOneElement>("select:not([multiple])#delete-realm-data-in"); | ||||||
|  |         const delete_data_value = $delete_in.val()!; | ||||||
|  |  | ||||||
|  |         if (delete_data_value === "null") { | ||||||
|  |             return $t({defaultMessage: "Data will not be automatically deleted"}); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let time_in_minutes: number; | ||||||
|  |         if (delete_data_value === "custom") { | ||||||
|  |             if (!util.validate_custom_time_input(custom_deletion_time_input)) { | ||||||
|  |                 return $t({defaultMessage: "Invalid custom time"}); | ||||||
|  |             } | ||||||
|  |             time_in_minutes = util.get_custom_time_in_minutes( | ||||||
|  |                 custom_deletion_time_unit, | ||||||
|  |                 custom_deletion_time_input, | ||||||
|  |             ); | ||||||
|  |             if (!is_valid_time_period(time_in_minutes)) { | ||||||
|  |                 return $t({defaultMessage: "Invalid custom time"}); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             // These options were already filtered for is_valid_time_period. | ||||||
|  |             time_in_minutes = Number.parseFloat(delete_data_value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (time_in_minutes === 0) { | ||||||
|  |             return $t({defaultMessage: "Data will be deleted immediately"}); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // The below is a duplicate of timerender.get_full_datetime, with a different base string. | ||||||
|  |         const valid_to = add(new Date(), {minutes: time_in_minutes}); | ||||||
|  |         const date = timerender.get_localized_date_or_time_for_format(valid_to, "dayofyear_year"); | ||||||
|  |         return $t({defaultMessage: "Data will be deleted after {date}"}, {date}); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const minimum_allowed_days = realm.server_min_deactivated_realm_deletion_days ?? 0; | ||||||
|  |     const maximum_allowed_days = realm.server_max_deactivated_realm_deletion_days; | ||||||
|  |  | ||||||
|  |     function is_valid_time_period(time_period: string | number): boolean { | ||||||
|  |         if (time_period === "custom") { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         if (time_period === "null") { | ||||||
|  |             if (maximum_allowed_days === null) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (typeof time_period === "number") { | ||||||
|  |             if (maximum_allowed_days === null) { | ||||||
|  |                 if (time_period >= minimum_allowed_days * 24 * 60) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 if ( | ||||||
|  |                     time_period >= minimum_allowed_days * 24 * 60 && | ||||||
|  |                     time_period <= maximum_allowed_days * 24 * 60 | ||||||
|  |                 ) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function get_custom_deletion_input_text(): string { | ||||||
|  |         if (maximum_allowed_days === null) { | ||||||
|  |             if (minimum_allowed_days === 0) { | ||||||
|  |                 // If there's no limit at all, avoid showing 0+. It's | ||||||
|  |                 // not a marginal string for translators, since we use | ||||||
|  |                 // that string elsewhere. | ||||||
|  |                 return $t({defaultMessage: "Custom time"}); | ||||||
|  |             } | ||||||
|  |             return $t({defaultMessage: `Custom time ({min}+ days)`}, {min: minimum_allowed_days}); | ||||||
|  |         } | ||||||
|  |         return $t( | ||||||
|  |             {defaultMessage: `Custom time ({min}-{max} days)`}, | ||||||
|  |             {min: minimum_allowed_days, max: maximum_allowed_days}, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function toggle_deactivate_submit_button(): void { | ||||||
|  |         const $delete_in = $<HTMLSelectOneElement>("select:not([multiple])#delete-realm-data-in"); | ||||||
|  |         const valid_custom_time = | ||||||
|  |             util.validate_custom_time_input(custom_deletion_time_input) && | ||||||
|  |             is_valid_time_period( | ||||||
|  |                 util.get_custom_time_in_minutes( | ||||||
|  |                     custom_deletion_time_unit, | ||||||
|  |                     custom_deletion_time_input, | ||||||
|  |                 ), | ||||||
|  |             ); | ||||||
|  |         $("#deactivate-realm-user-modal .dialog_submit_button").prop( | ||||||
|  |             "disabled", | ||||||
|  |             $delete_in.val() === "custom" && !valid_custom_time, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function deactivate_realm_modal_post_render(): void { | ||||||
|  |         settings_components.set_custom_time_inputs_visibility( | ||||||
|  |             $("#delete-realm-data-in"), | ||||||
|  |             custom_deletion_time_unit, | ||||||
|  |             custom_deletion_time_input, | ||||||
|  |         ); | ||||||
|  |         settings_components.set_time_input_formatted_text( | ||||||
|  |             $("#delete-realm-data-in"), | ||||||
|  |             delete_data_in_text(), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         $("#delete-realm-data-in").on("change", () => { | ||||||
|  |             // If the user navigates away and back to the custom | ||||||
|  |             // time input, we show a better value than "NaN" if | ||||||
|  |             // the previous value was invalid. | ||||||
|  |             if (!util.validate_custom_time_input(custom_deletion_time_input)) { | ||||||
|  |                 custom_deletion_time_input = 0; | ||||||
|  |             } | ||||||
|  |             settings_components.set_custom_time_inputs_visibility( | ||||||
|  |                 $("#delete-realm-data-in"), | ||||||
|  |                 custom_deletion_time_unit, | ||||||
|  |                 custom_deletion_time_input, | ||||||
|  |             ); | ||||||
|  |             settings_components.set_time_input_formatted_text( | ||||||
|  |                 $("#delete-realm-data-in"), | ||||||
|  |                 delete_data_in_text(), | ||||||
|  |             ); | ||||||
|  |             toggle_deactivate_submit_button(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         $("#custom-deletion-time-input").on("keydown", (e) => { | ||||||
|  |             if (e.key === "Enter") { | ||||||
|  |                 // Prevent submitting the realm deactivation form via Enter. | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         $("#custom-realm-deletion-time").on( | ||||||
|  |             "input propertychange", | ||||||
|  |             ".custom-time-input-value, .custom-time-input-unit", | ||||||
|  |             () => { | ||||||
|  |                 custom_deletion_time_input = util.check_time_input( | ||||||
|  |                     $<HTMLInputElement>("input#custom-deletion-time-input").val()!, | ||||||
|  |                 ); | ||||||
|  |                 custom_deletion_time_unit = $<HTMLSelectOneElement>( | ||||||
|  |                     "select:not([multiple])#custom-deletion-time-unit", | ||||||
|  |                 ).val()!; | ||||||
|  |                 settings_components.set_time_input_formatted_text( | ||||||
|  |                     $("#delete-realm-data-in"), | ||||||
|  |                     delete_data_in_text(), | ||||||
|  |                 ); | ||||||
|  |                 toggle_deactivate_submit_button(); | ||||||
|  |             }, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const all_delete_options = Object.values(settings_config.realm_deletion_in_values); | ||||||
|  |     const valid_delete_options = all_delete_options.filter((option) => | ||||||
|  |         is_valid_time_period(option.value), | ||||||
|  |     ); | ||||||
|  |     const time_unit_choices = [ | ||||||
|  |         settings_config.custom_time_unit_values.days, | ||||||
|  |         settings_config.custom_time_unit_values.weeks, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     const html_body = render_settings_deactivate_realm_modal({ | ||||||
|  |         delete_in_options: valid_delete_options, | ||||||
|  |         custom_deletion_input_label: get_custom_deletion_input_text(), | ||||||
|  |         time_choices: time_unit_choices, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     dialog_widget.launch({ |     dialog_widget.launch({ | ||||||
|         html_heading: $t_html({defaultMessage: "Deactivate organization"}), |         html_heading: $t_html({defaultMessage: "Deactivate organization"}), | ||||||
|         help_link: "/help/deactivate-your-organization", |         help_link: "/help/deactivate-your-organization", | ||||||
|         html_body, |         html_body, | ||||||
|  |         id: "deactivate-realm-user-modal", | ||||||
|         on_click: do_deactivate_realm, |         on_click: do_deactivate_realm, | ||||||
|         close_on_submit: false, |         close_on_submit: false, | ||||||
|         focus_submit_on_open: true, |         focus_submit_on_open: true, | ||||||
|         html_submit_button: $t_html({defaultMessage: "Confirm"}), |         html_submit_button: $t_html({defaultMessage: "Confirm"}), | ||||||
|  |         post_render: deactivate_realm_modal_post_render, | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -398,6 +398,8 @@ export const realm_schema = z.object({ | |||||||
|     server_emoji_data_url: z.string(), |     server_emoji_data_url: z.string(), | ||||||
|     server_inline_image_preview: z.boolean(), |     server_inline_image_preview: z.boolean(), | ||||||
|     server_inline_url_embed_preview: z.boolean(), |     server_inline_url_embed_preview: z.boolean(), | ||||||
|  |     server_max_deactivated_realm_deletion_days: z.nullable(z.number()), | ||||||
|  |     server_min_deactivated_realm_deletion_days: z.nullable(z.number()), | ||||||
|     server_jitsi_server_url: z.nullable(z.string()), |     server_jitsi_server_url: z.nullable(z.string()), | ||||||
|     server_name_changes_disabled: z.boolean(), |     server_name_changes_disabled: z.boolean(), | ||||||
|     server_needs_upgrade: z.boolean(), |     server_needs_upgrade: z.boolean(), | ||||||
|   | |||||||
| @@ -1,2 +1,22 @@ | |||||||
| <p>{{t "This action is permanent and cannot be undone. All users will permanently lose access to their Zulip accounts." }}</p> | <form id="realm-data-deletion-form"> | ||||||
| <p>{{t "Are you sure you want to deactivate this organization?"}}</p> |     <div class="input-group"> | ||||||
|  |         <label for="delete-realm-data-in" class="modal-field-label">{{t "After how much time should all data for this organization be permanently deleted (users, channels, messages, etc.)?" }}</label> | ||||||
|  |         <select id="delete-realm-data-in" name="delete-realm-data-in" class="modal_select bootstrap-focus-style"> | ||||||
|  |             {{#each delete_in_options}} | ||||||
|  |                 <option {{#if this.default }}selected{{/if}} value="{{this.value}}">{{this.description}}</option> | ||||||
|  |             {{/each}} | ||||||
|  |         </select> | ||||||
|  |         <p class="time-input-formatted-description"></p> | ||||||
|  |         <div id="custom-realm-deletion-time" class="dependent-settings-block custom-time-input-container"> | ||||||
|  |             <label class="modal-field-label">{{custom_deletion_input_label}}</label> | ||||||
|  |             <input id="custom-deletion-time-input" name="custom-deletion-time-input" class="custom-time-input-value inline-block modal_text_input" type="text" autocomplete="off" value="" maxlength="4"/> | ||||||
|  |             <select id="custom-deletion-time-unit" name="custom-deletion-time-unit" class="custom-time-input-unit bootstrap-focus-style modal_select" > | ||||||
|  |                 {{#each time_choices}} | ||||||
|  |                     <option value="{{this.name}}">{{this.description}}</option> | ||||||
|  |                 {{/each}} | ||||||
|  |             </select> | ||||||
|  |             <p class="custom-time-input-formatted-description"></p> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | </form> | ||||||
|  | <p>{{t "Are you sure you want to deactivate this organization? All users will lose access to their Zulip accounts." }}</p> | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import datetime | ||||||
| import logging | import logging | ||||||
| import zoneinfo | import zoneinfo | ||||||
| from email.headerregistry import Address | from email.headerregistry import Address | ||||||
| @@ -16,6 +17,7 @@ from zerver.actions.user_groups import update_users_in_full_members_system_group | |||||||
| from zerver.actions.user_settings import do_delete_avatar_image | from zerver.actions.user_settings import do_delete_avatar_image | ||||||
| from zerver.lib.exceptions import JsonableError | from zerver.lib.exceptions import JsonableError | ||||||
| from zerver.lib.message import parse_message_time_limit_setting, update_first_visible_message_id | from zerver.lib.message import parse_message_time_limit_setting, update_first_visible_message_id | ||||||
|  | from zerver.lib.queue import queue_json_publish_rollback_unsafe | ||||||
| from zerver.lib.retention import move_messages_to_archive | from zerver.lib.retention import move_messages_to_archive | ||||||
| from zerver.lib.send_email import FromAddress, send_email, send_email_to_admins | from zerver.lib.send_email import FromAddress, send_email, send_email_to_admins | ||||||
| from zerver.lib.sessions import delete_realm_user_sessions | from zerver.lib.sessions import delete_realm_user_sessions | ||||||
| @@ -517,6 +519,7 @@ def do_deactivate_realm( | |||||||
|     *, |     *, | ||||||
|     acting_user: UserProfile | None, |     acting_user: UserProfile | None, | ||||||
|     deactivation_reason: RealmDeactivationReasonType, |     deactivation_reason: RealmDeactivationReasonType, | ||||||
|  |     deletion_delay_days: int | None = None, | ||||||
|     email_owners: bool, |     email_owners: bool, | ||||||
| ) -> None: | ) -> None: | ||||||
|     """ |     """ | ||||||
| @@ -533,7 +536,13 @@ def do_deactivate_realm( | |||||||
|  |  | ||||||
|     with transaction.atomic(durable=True): |     with transaction.atomic(durable=True): | ||||||
|         realm.deactivated = True |         realm.deactivated = True | ||||||
|         realm.save(update_fields=["deactivated"]) |         if deletion_delay_days is None: | ||||||
|  |             realm.save(update_fields=["deactivated"]) | ||||||
|  |         else: | ||||||
|  |             realm.scheduled_deletion_date = timezone_now() + datetime.timedelta( | ||||||
|  |                 days=deletion_delay_days | ||||||
|  |             ) | ||||||
|  |             realm.save(update_fields=["scheduled_deletion_date", "deactivated"]) | ||||||
|  |  | ||||||
|         if settings.BILLING_ENABLED: |         if settings.BILLING_ENABLED: | ||||||
|             billing_session = RealmBillingSession(user=acting_user, realm=realm) |             billing_session = RealmBillingSession(user=acting_user, realm=realm) | ||||||
| @@ -566,6 +575,13 @@ def do_deactivate_realm( | |||||||
|         event = dict(type="realm", op="deactivated", realm_id=realm.id) |         event = dict(type="realm", op="deactivated", realm_id=realm.id) | ||||||
|         send_event_on_commit(realm, event, active_user_ids(realm.id)) |         send_event_on_commit(realm, event, active_user_ids(realm.id)) | ||||||
|  |  | ||||||
|  |         if deletion_delay_days == 0: | ||||||
|  |             event = { | ||||||
|  |                 "type": "scrub_deactivated_realm", | ||||||
|  |                 "realm_id": realm.id, | ||||||
|  |             } | ||||||
|  |             queue_json_publish_rollback_unsafe("deferred_work", event) | ||||||
|  |  | ||||||
|     # Don't deactivate the users, as that would lose a lot of state if |     # Don't deactivate the users, as that would lose a lot of state if | ||||||
|     # the realm needs to be reactivated, but do delete their sessions |     # the realm needs to be reactivated, but do delete their sessions | ||||||
|     # so they get bumped to the login screen, where they'll get a |     # so they get bumped to the login screen, where they'll get a | ||||||
| @@ -590,8 +606,9 @@ def do_reactivate_realm(realm: Realm) -> None: | |||||||
|         return |         return | ||||||
|  |  | ||||||
|     realm.deactivated = False |     realm.deactivated = False | ||||||
|  |     realm.scheduled_deletion_date = None | ||||||
|     with transaction.atomic(durable=True): |     with transaction.atomic(durable=True): | ||||||
|         realm.save(update_fields=["deactivated"]) |         realm.save(update_fields=["deactivated", "scheduled_deletion_date"]) | ||||||
|  |  | ||||||
|         event_time = timezone_now() |         event_time = timezone_now() | ||||||
|         RealmAuditLog.objects.create( |         RealmAuditLog.objects.create( | ||||||
| @@ -636,6 +653,7 @@ def do_delete_all_realm_attachments(realm: Realm, *, batch_size: int = 1000) -> | |||||||
|         obj_class._default_manager.filter(realm=realm).delete() |         obj_class._default_manager.filter(realm=realm).delete() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @transaction.atomic(durable=True) | ||||||
| def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None: | def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None: | ||||||
|     if settings.BILLING_ENABLED: |     if settings.BILLING_ENABLED: | ||||||
|         from corporate.lib.stripe import RealmBillingSession |         from corporate.lib.stripe import RealmBillingSession | ||||||
| @@ -691,6 +709,20 @@ def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None: | |||||||
|         acting_user=acting_user, |         acting_user=acting_user, | ||||||
|         event_type=AuditLogEventType.REALM_SCRUBBED, |         event_type=AuditLogEventType.REALM_SCRUBBED, | ||||||
|     ) |     ) | ||||||
|  |     realm.scheduled_deletion_date = None | ||||||
|  |     realm.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def scrub_deactivated_realm(realm_to_scrub: Realm) -> None: | ||||||
|  |     if ( | ||||||
|  |         realm_to_scrub.scheduled_deletion_date is not None | ||||||
|  |         and realm_to_scrub.scheduled_deletion_date <= timezone_now() | ||||||
|  |     ): | ||||||
|  |         assert ( | ||||||
|  |             realm_to_scrub.deactivated | ||||||
|  |         ), "Non-deactivated realm unexpectedly scheduled for deletion." | ||||||
|  |         do_scrub_realm(realm_to_scrub, acting_user=None) | ||||||
|  |         logging.info("Scrubbed realm %s", realm_to_scrub.id) | ||||||
|  |  | ||||||
|  |  | ||||||
| @transaction.atomic(durable=True) | @transaction.atomic(durable=True) | ||||||
|   | |||||||
| @@ -471,6 +471,14 @@ def fetch_initial_state_data( | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         state["server_supported_permission_settings"] = get_server_supported_permission_settings() |         state["server_supported_permission_settings"] = get_server_supported_permission_settings() | ||||||
|  |  | ||||||
|  |         state["server_min_deactivated_realm_deletion_days"] = ( | ||||||
|  |             settings.MIN_DEACTIVATED_REALM_DELETION_DAYS | ||||||
|  |         ) | ||||||
|  |         state["server_max_deactivated_realm_deletion_days"] = ( | ||||||
|  |             settings.MAX_DEACTIVATED_REALM_DELETION_DAYS | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     if want("realm_user_settings_defaults"): |     if want("realm_user_settings_defaults"): | ||||||
|         realm_user_default = RealmUserDefault.objects.get(realm=realm) |         realm_user_default = RealmUserDefault.objects.get(realm=realm) | ||||||
|         state["realm_user_settings_defaults"] = {} |         state["realm_user_settings_defaults"] = {} | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								zerver/migrations/0643_realm_scheduled_deletion_date.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								zerver/migrations/0643_realm_scheduled_deletion_date.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | # Generated by Django 5.0.9 on 2024-10-12 07:03 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("zerver", "0642_realm_moderation_request_channel"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="realm", | ||||||
|  |             name="scheduled_deletion_date", | ||||||
|  |             field=models.DateTimeField(db_index=True, default=None, null=True), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -184,6 +184,7 @@ class Realm(models.Model):  # type: ignore[django-manager-missing] # django-stub | |||||||
|     push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True) |     push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True) | ||||||
|  |  | ||||||
|     date_created = models.DateTimeField(default=timezone_now) |     date_created = models.DateTimeField(default=timezone_now) | ||||||
|  |     scheduled_deletion_date = models.DateTimeField(default=None, db_index=True, null=True) | ||||||
|     demo_organization_scheduled_deletion_date = models.DateTimeField(default=None, null=True) |     demo_organization_scheduled_deletion_date = models.DateTimeField(default=None, null=True) | ||||||
|     deactivated = models.BooleanField(default=False) |     deactivated = models.BooleanField(default=False) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14388,6 +14388,30 @@ paths: | |||||||
| 
 | 
 | ||||||
|                           **Changes**: New in Zulip 4.0 (feature level 53). Previously, |                           **Changes**: New in Zulip 4.0 (feature level 53). Previously, | ||||||
|                           this property always had a value of 10000. |                           this property always had a value of 10000. | ||||||
|  |                       server_min_deactivated_realm_deletion_days: | ||||||
|  |                         type: integer | ||||||
|  |                         nullable: true | ||||||
|  |                         description: | | ||||||
|  |                           Present if `realm` is present in `fetch_event_types`. | ||||||
|  | 
 | ||||||
|  |                           The minimum permitted number of days before full data deletion | ||||||
|  |                           (users, channels, messages, etc.) of a deactivated organization. | ||||||
|  |                           If `null`, then a deactivated organization's data can be | ||||||
|  |                           deleted immediately. | ||||||
|  | 
 | ||||||
|  |                           **Changes**: New in Zulip 10.0 (feature level 332) | ||||||
|  |                       server_max_deactivated_realm_deletion_days: | ||||||
|  |                         type: integer | ||||||
|  |                         nullable: true | ||||||
|  |                         description: | | ||||||
|  |                           Present if `realm` is present in `fetch_event_types`. | ||||||
|  | 
 | ||||||
|  |                           The maximum permitted number of days before full data deletion | ||||||
|  |                           (users, channels, messages, etc.) of a deactivated organization. | ||||||
|  |                           If `null`, then a deactivated organization's data can be | ||||||
|  |                           retained indefinitely. | ||||||
|  | 
 | ||||||
|  |                           **Changes**: New in Zulip 10.0 (feature level 332). | ||||||
|                       server_presence_ping_interval_seconds: |                       server_presence_ping_interval_seconds: | ||||||
|                         type: integer |                         type: integer | ||||||
|                         description: | |                         description: | | ||||||
|   | |||||||
| @@ -222,6 +222,8 @@ class HomeTest(ZulipTestCase): | |||||||
|         "server_generation", |         "server_generation", | ||||||
|         "server_inline_image_preview", |         "server_inline_image_preview", | ||||||
|         "server_inline_url_embed_preview", |         "server_inline_url_embed_preview", | ||||||
|  |         "server_max_deactivated_realm_deletion_days", | ||||||
|  |         "server_min_deactivated_realm_deletion_days", | ||||||
|         "server_jitsi_server_url", |         "server_jitsi_server_url", | ||||||
|         "server_name_changes_disabled", |         "server_name_changes_disabled", | ||||||
|         "server_needs_upgrade", |         "server_needs_upgrade", | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ from typing import Any | |||||||
| from unittest import mock, skipUnless | from unittest import mock, skipUnless | ||||||
|  |  | ||||||
| import orjson | import orjson | ||||||
|  | import time_machine | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core import mail | from django.core import mail | ||||||
| from django.test import override_settings | from django.test import override_settings | ||||||
| @@ -36,6 +37,7 @@ from zerver.actions.realm_settings import ( | |||||||
|     do_set_realm_authentication_methods, |     do_set_realm_authentication_methods, | ||||||
|     do_set_realm_property, |     do_set_realm_property, | ||||||
|     do_set_realm_user_default_setting, |     do_set_realm_user_default_setting, | ||||||
|  |     scrub_deactivated_realm, | ||||||
| ) | ) | ||||||
| from zerver.actions.streams import do_deactivate_stream, merge_streams | from zerver.actions.streams import do_deactivate_stream, merge_streams | ||||||
| from zerver.actions.user_groups import check_add_user_group | from zerver.actions.user_groups import check_add_user_group | ||||||
| @@ -423,12 +425,17 @@ class RealmTest(ZulipTestCase): | |||||||
|     def test_do_reactivate_realm(self) -> None: |     def test_do_reactivate_realm(self) -> None: | ||||||
|         realm = get_realm("zulip") |         realm = get_realm("zulip") | ||||||
|         do_deactivate_realm( |         do_deactivate_realm( | ||||||
|             realm, acting_user=None, deactivation_reason="owner_request", email_owners=False |             realm, | ||||||
|  |             acting_user=None, | ||||||
|  |             deactivation_reason="owner_request", | ||||||
|  |             email_owners=False, | ||||||
|  |             deletion_delay_days=15, | ||||||
|         ) |         ) | ||||||
|         self.assertTrue(realm.deactivated) |         self.assertTrue(realm.deactivated) | ||||||
|  |  | ||||||
|         do_reactivate_realm(realm) |         do_reactivate_realm(realm) | ||||||
|         self.assertFalse(realm.deactivated) |         self.assertFalse(realm.deactivated) | ||||||
|  |         self.assertEqual(realm.scheduled_deletion_date, None) | ||||||
|  |  | ||||||
|         log_entry = RealmAuditLog.objects.last() |         log_entry = RealmAuditLog.objects.last() | ||||||
|         assert log_entry is not None |         assert log_entry is not None | ||||||
| @@ -998,6 +1005,99 @@ class RealmTest(ZulipTestCase): | |||||||
|         result = self.client_patch("/json/realm", req) |         result = self.client_patch("/json/realm", req) | ||||||
|         self.assert_json_success(result) |         self.assert_json_success(result) | ||||||
|  |  | ||||||
|  |     def test_data_deletion_schedule_when_deactivating_realm(self) -> None: | ||||||
|  |         self.login("desdemona") | ||||||
|  |  | ||||||
|  |         # settings.MIN_DEACTIVATED_REALM_DELETION_DAYS have default value 14. | ||||||
|  |         # So minimum 14 days should be given for data deletion. | ||||||
|  |         result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 12}) | ||||||
|  |         self.assert_json_error(result, "Data deletion time must be at least 14 days in the future.") | ||||||
|  |  | ||||||
|  |         result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 17}) | ||||||
|  |         self.assert_json_success(result) | ||||||
|  |  | ||||||
|  |         do_reactivate_realm(get_realm("zulip")) | ||||||
|  |  | ||||||
|  |         with self.settings(MIN_DEACTIVATED_REALM_DELETION_DAYS=None): | ||||||
|  |             self.login("desdemona") | ||||||
|  |  | ||||||
|  |             result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 12}) | ||||||
|  |             self.assert_json_success(result) | ||||||
|  |  | ||||||
|  |         do_reactivate_realm(get_realm("zulip")) | ||||||
|  |  | ||||||
|  |         with self.settings(MAX_DEACTIVATED_REALM_DELETION_DAYS=30): | ||||||
|  |             self.login("desdemona") | ||||||
|  |  | ||||||
|  |             # None value to deletion_delay_days means data will be never deleted. | ||||||
|  |             result = self.client_post( | ||||||
|  |                 "/json/realm/deactivate", {"deletion_delay_days": orjson.dumps(None).decode()} | ||||||
|  |             ) | ||||||
|  |             self.assert_json_error( | ||||||
|  |                 result, | ||||||
|  |                 "Data deletion time must be at most 30 days in the future.", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 40}) | ||||||
|  |             self.assert_json_error( | ||||||
|  |                 result, | ||||||
|  |                 "Data deletion time must be at most 30 days in the future.", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 25}) | ||||||
|  |             self.assert_json_success(result) | ||||||
|  |  | ||||||
|  |     def test_scrub_deactivated_realms(self) -> None: | ||||||
|  |         zulip = get_realm("zulip") | ||||||
|  |         zephyr = get_realm("zephyr") | ||||||
|  |         lear = get_realm("lear") | ||||||
|  |  | ||||||
|  |         do_deactivate_realm( | ||||||
|  |             zephyr, | ||||||
|  |             acting_user=None, | ||||||
|  |             deletion_delay_days=3, | ||||||
|  |             deactivation_reason="owner_request", | ||||||
|  |             email_owners=False, | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(zephyr.deactivated) | ||||||
|  |  | ||||||
|  |         do_deactivate_realm( | ||||||
|  |             zulip, | ||||||
|  |             acting_user=None, | ||||||
|  |             deletion_delay_days=None, | ||||||
|  |             deactivation_reason="owner_request", | ||||||
|  |             email_owners=False, | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(zulip.deactivated) | ||||||
|  |  | ||||||
|  |         with mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm: | ||||||
|  |             scrub_deactivated_realm(zephyr) | ||||||
|  |             scrub_deactivated_realm(zulip) | ||||||
|  |             mock_scrub_realm.assert_not_called() | ||||||
|  |  | ||||||
|  |         with ( | ||||||
|  |             mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm, | ||||||
|  |             self.assertLogs(level="INFO"), | ||||||
|  |         ): | ||||||
|  |             do_deactivate_realm( | ||||||
|  |                 lear, | ||||||
|  |                 acting_user=None, | ||||||
|  |                 deletion_delay_days=0, | ||||||
|  |                 deactivation_reason="owner_request", | ||||||
|  |                 email_owners=False, | ||||||
|  |             ) | ||||||
|  |             self.assertTrue(lear.deactivated) | ||||||
|  |             mock_scrub_realm.assert_called_once_with(lear, acting_user=None) | ||||||
|  |  | ||||||
|  |         with ( | ||||||
|  |             time_machine.travel(timezone_now() + timedelta(days=4), tick=False), | ||||||
|  |             mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm, | ||||||
|  |             self.assertLogs(level="INFO"), | ||||||
|  |         ): | ||||||
|  |             scrub_deactivated_realm(get_realm("zephyr")) | ||||||
|  |             scrub_deactivated_realm(get_realm("zulip")) | ||||||
|  |             mock_scrub_realm.assert_called_once_with(zephyr, acting_user=None) | ||||||
|  |  | ||||||
|     def test_initial_plan_type(self) -> None: |     def test_initial_plan_type(self) -> None: | ||||||
|         with self.settings(BILLING_ENABLED=True): |         with self.settings(BILLING_ENABLED=True): | ||||||
|             self.assertEqual(do_create_realm("hosted", "hosted").plan_type, Realm.PLAN_TYPE_LIMITED) |             self.assertEqual(do_create_realm("hosted", "hosted").plan_type, Realm.PLAN_TYPE_LIMITED) | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| from collections.abc import Mapping | from collections.abc import Mapping | ||||||
| from typing import Annotated, Any, Literal | from typing import Annotated, Any, Literal | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| @@ -34,11 +35,7 @@ from zerver.lib.i18n import get_available_language_codes | |||||||
| from zerver.lib.response import json_success | from zerver.lib.response import json_success | ||||||
| from zerver.lib.retention import parse_message_retention_days | from zerver.lib.retention import parse_message_retention_days | ||||||
| from zerver.lib.streams import access_stream_by_id | from zerver.lib.streams import access_stream_by_id | ||||||
| from zerver.lib.typed_endpoint import ( | from zerver.lib.typed_endpoint import ApiParamConfig, typed_endpoint | ||||||
|     ApiParamConfig, |  | ||||||
|     typed_endpoint, |  | ||||||
|     typed_endpoint_without_parameters, |  | ||||||
| ) |  | ||||||
| from zerver.lib.typed_endpoint_validators import check_int_in_validator, check_string_in_validator | from zerver.lib.typed_endpoint_validators import check_int_in_validator, check_string_in_validator | ||||||
| from zerver.lib.user_groups import ( | from zerver.lib.user_groups import ( | ||||||
|     GroupSettingChangeRequest, |     GroupSettingChangeRequest, | ||||||
| @@ -498,11 +495,38 @@ def update_realm( | |||||||
|  |  | ||||||
|  |  | ||||||
| @require_realm_owner | @require_realm_owner | ||||||
| @typed_endpoint_without_parameters | @typed_endpoint | ||||||
| def deactivate_realm(request: HttpRequest, user: UserProfile) -> HttpResponse: | def deactivate_realm( | ||||||
|  |     request: HttpRequest, user: UserProfile, *, deletion_delay_days: Json[int | None] = None | ||||||
|  | ) -> HttpResponse: | ||||||
|  |     if settings.MAX_DEACTIVATED_REALM_DELETION_DAYS is not None and ( | ||||||
|  |         deletion_delay_days is None | ||||||
|  |         or deletion_delay_days > settings.MAX_DEACTIVATED_REALM_DELETION_DAYS | ||||||
|  |     ): | ||||||
|  |         raise JsonableError( | ||||||
|  |             _("Data deletion time must be at most {max_allowed_days} days in the future.").format( | ||||||
|  |                 max_allowed_days=settings.MAX_DEACTIVATED_REALM_DELETION_DAYS, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     if ( | ||||||
|  |         settings.MIN_DEACTIVATED_REALM_DELETION_DAYS is not None | ||||||
|  |         and deletion_delay_days is not None | ||||||
|  |         and deletion_delay_days < settings.MIN_DEACTIVATED_REALM_DELETION_DAYS | ||||||
|  |     ): | ||||||
|  |         raise JsonableError( | ||||||
|  |             _("Data deletion time must be at least {min_allowed_days} days in the future.").format( | ||||||
|  |                 min_allowed_days=settings.MIN_DEACTIVATED_REALM_DELETION_DAYS, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     realm = user.realm |     realm = user.realm | ||||||
|     do_deactivate_realm( |     do_deactivate_realm( | ||||||
|         realm, acting_user=user, deactivation_reason="owner_request", email_owners=True |         realm, | ||||||
|  |         acting_user=user, | ||||||
|  |         deactivation_reason="owner_request", | ||||||
|  |         email_owners=True, | ||||||
|  |         deletion_delay_days=deletion_delay_days, | ||||||
|     ) |     ) | ||||||
|     return json_success(request) |     return json_success(request) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ from typing_extensions import override | |||||||
| from zerver.actions.message_flags import do_mark_stream_messages_as_read | from zerver.actions.message_flags import do_mark_stream_messages_as_read | ||||||
| from zerver.actions.message_send import internal_send_private_message | from zerver.actions.message_send import internal_send_private_message | ||||||
| from zerver.actions.realm_export import notify_realm_export | from zerver.actions.realm_export import notify_realm_export | ||||||
|  | from zerver.actions.realm_settings import scrub_deactivated_realm | ||||||
| from zerver.lib.export import export_realm_wrapper | from zerver.lib.export import export_realm_wrapper | ||||||
| from zerver.lib.push_notifications import clear_push_device_tokens | from zerver.lib.push_notifications import clear_push_device_tokens | ||||||
| from zerver.lib.queue import queue_json_publish_rollback_unsafe, retry_event | from zerver.lib.queue import queue_json_publish_rollback_unsafe, retry_event | ||||||
| @@ -228,6 +229,13 @@ class DeferredWorker(QueueProcessingWorker): | |||||||
|             realm_id = event["realm_id"] |             realm_id = event["realm_id"] | ||||||
|             logger.info("Updating push bouncer with metadata on behalf of realm %s", realm_id) |             logger.info("Updating push bouncer with metadata on behalf of realm %s", realm_id) | ||||||
|             send_server_data_to_push_bouncer(consider_usage_statistics=False) |             send_server_data_to_push_bouncer(consider_usage_statistics=False) | ||||||
|  |         elif event["type"] == "scrub_deactivated_realm": | ||||||
|  |             realms_to_scrub = Realm.objects.filter( | ||||||
|  |                 deactivated=True, | ||||||
|  |                 scheduled_deletion_date__lte=timezone_now(), | ||||||
|  |             ) | ||||||
|  |             for realm in realms_to_scrub: | ||||||
|  |                 scrub_deactivated_realm(realm) | ||||||
|  |  | ||||||
|         end = time.time() |         end = time.time() | ||||||
|         logger.info( |         logger.info( | ||||||
|   | |||||||
| @@ -678,3 +678,10 @@ RESOLVE_TOPIC_UNDO_GRACE_PERIOD_SECONDS = 60 | |||||||
| # For realm imports during registration, maximum size of file | # For realm imports during registration, maximum size of file | ||||||
| # that can be uploaded. | # that can be uploaded. | ||||||
| MAX_WEB_DATA_IMPORT_SIZE_MB = 1024 | MAX_WEB_DATA_IMPORT_SIZE_MB = 1024 | ||||||
|  |  | ||||||
|  | # Minimum and maximum permitted number of days before full data | ||||||
|  | # deletion when deactivating an organization. A nonzero minimum helps | ||||||
|  | # protect against a compromised administrator account being used to | ||||||
|  | # delete an active organization. | ||||||
|  | MIN_DEACTIVATED_REALM_DELETION_DAYS: int | None = 14 | ||||||
|  | MAX_DEACTIVATED_REALM_DELETION_DAYS: int | None = None | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user