Privacy Policy DSGVO with sheets for categories, export-, check- and first erase-functions

This commit is contained in:
2026-08-05 20:34:38 +00:00
parent c9e6438222
commit fc82a28290
19 changed files with 1917 additions and 5 deletions
+374
View File
@@ -0,0 +1,374 @@
{% extends "base.html" %}
{% block content %}
<div class="toolbar privacy-toolbar">
<div>
<h1>{{ t('privacy.title') }}</h1>
<p class="muted">{{ t('privacy.intro') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/settings/privacy/export.txt">{{ t('privacy.export_text') }}</a>
<a class="button button-secondary" href="/settings/privacy/export.html">{{ t('privacy.export_html') }}</a>
<a class="button" href="/settings/privacy/export.xlsx">{{ t('privacy.export_excel') }}</a>
</div>
</div>
<div class="notice warning privacy-phase-notice">
<strong>{{ t('privacy.phase2_title') }}</strong>
{{ t('privacy.phase2_help') }}
</div>
{% if not can_edit %}<div class="notice info"><strong>{{ t('privacy.read_only_title') }}</strong> {{ t('privacy.read_only_help') }}</div>{% endif %}
<form method="post" action="/settings/privacy" class="settings-form privacy-settings-form" data-privacy-readonly="{{ 'false' if can_edit else 'true' }}">
{% set activity = privacy_settings.processing_activity %}
<section class="settings-section">
<h2>{{ t('privacy.processing_activity') }}</h2>
<div class="form-grid two-columns">
<label>{{ t('privacy.activity_title') }}<input type="text" name="title" value="{{ activity.title }}" required></label>
<label>{{ t('privacy.document_version') }}<input type="text" name="document_version" value="{{ activity.document_version }}"></label>
<label>{{ t('privacy.controller') }}<input type="text" name="controller" value="{{ activity.controller }}"></label>
<label>{{ t('privacy.responsible_department') }}<input type="text" name="responsible_department" value="{{ activity.responsible_department }}"></label>
<label>{{ t('privacy.responsible_people') }}<input type="text" name="responsible_people" value="{{ activity.responsible_people }}"></label>
<label>{{ t('privacy.data_protection_contact') }}<input type="text" name="data_protection_contact" value="{{ activity.data_protection_contact }}"></label>
</div>
<label>{{ t('privacy.legal_basis') }}<textarea name="legal_basis" rows="3">{{ activity.legal_basis }}</textarea></label>
<label>{{ t('privacy.data_subject_groups') }}<textarea name="data_subject_groups" rows="3">{{ activity.data_subject_groups }}</textarea></label>
</section>
<section class="settings-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.purposes') }}</h2><p class="muted">{{ t('privacy.purposes_help') }}</p></div>
<button type="button" class="button-secondary" data-add-list-row="purposes-list">{{ t('common.add', 'Add') }}</button>
</div>
<div id="purposes-list" class="repeatable-list">
{% for value in activity.purposes %}
<div class="repeatable-row"><input type="text" name="purposes" value="{{ value }}"><button type="button" class="button-danger button-small" data-remove-row>×</button></div>
{% endfor %}
</div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.recipients_transfers') }}</h2>
<div class="form-grid two-columns">
<label>{{ t('privacy.internal_recipients') }}<textarea name="internal_recipients" rows="3">{{ activity.internal_recipients }}</textarea></label>
<label>{{ t('privacy.external_recipients') }}<textarea name="external_recipients" rows="3">{{ activity.external_recipients }}</textarea></label>
</div>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="third_country_transfer" {% if activity.third_country_transfer %}checked{% endif %}> {{ t('privacy.third_country_transfer') }}</label>
<label>{{ t('privacy.third_country_details') }}<textarea name="third_country_details" rows="3">{{ activity.third_country_details }}</textarea></label>
</section>
<section class="settings-section">
<h2>{{ t('privacy.systems_backups') }}</h2>
<label>{{ t('privacy.systems') }}<textarea name="systems" rows="3">{{ activity.systems }}</textarea></label>
<div class="form-grid two-columns">
<label>{{ t('privacy.backup_system') }}<input type="text" name="backup_system" value="{{ activity.backup_system }}"></label>
<label>{{ t('privacy.backup_retention_days') }}<input type="number" min="0" max="36500" name="backup_retention_days" value="{{ activity.backup_retention_days if activity.backup_retention_days is not none else '' }}"></label>
</div>
<label>{{ t('privacy.restore_deletion_procedure') }}<textarea name="restore_deletion_procedure" rows="3">{{ activity.restore_deletion_procedure }}</textarea></label>
</section>
<section class="settings-section privacy-category-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.data_categories') }}</h2><p class="muted">{{ t('privacy.data_categories_tabs_help') }}</p></div>
{% if can_edit %}<button type="button" class="button-secondary" id="add-privacy-category">{{ t('privacy.add_category') }}</button>{% endif %}
</div>
<div class="privacy-category-workbook" data-category-workbook>
<div class="privacy-category-tabs" role="tablist" aria-label="{{ t('privacy.data_categories') }}" data-category-tabs>
{% for category in privacy_settings.categories %}
<button type="button" class="privacy-category-tab{% if loop.first %} active{% endif %}" role="tab" aria-selected="{{ 'true' if loop.first else 'false' }}" aria-controls="privacy-category-panel-{{ loop.index0 }}" data-category-tab>
<span data-category-tab-title>{{ category.name }}</span>{% if category.is_system %}<span class="privacy-system-badge" title="{{ t('privacy.system_category_help') }}">🔒</span>{% endif %}
</button>
{% endfor %}
</div>
<div id="privacy-category-list" class="privacy-category-panels" data-category-panels>
{% for category in privacy_settings.categories %}
<section id="privacy-category-panel-{{ loop.index0 }}" class="privacy-category-panel{% if loop.first %} active{% endif %}" role="tabpanel" {% if not loop.first %}hidden{% endif %} data-category-panel>
<input type="hidden" name="category_key" value="{{ category.key }}">
<div class="form-grid two-columns">
<label>{{ t('privacy.category_name') }}<input type="text" name="category_name" value="{{ category.name }}" data-category-name></label>
<label>{{ t('privacy.system') }}<input type="text" name="category_system" value="{{ category.system }}"></label>
</div>
<label>{{ t('privacy.data_subjects') }}<textarea name="category_data_subjects" rows="2">{{ category.data_subjects }}</textarea></label>
<label>{{ t('privacy.personal_data') }}<textarea name="category_personal_data" rows="3">{{ category.personal_data }}</textarea></label>
<label>{{ t('privacy.purpose') }}<textarea name="category_purpose" rows="2">{{ category.purpose }}</textarea></label>
<div class="form-grid three-columns">
<label>{{ t('privacy.retention_type') }}
<select name="category_retention_type">
{% for value, key in [
('days','privacy.retention_days_type'),
('manual_parent_delete','privacy.retention_manual_parent'),
('overwrite_parent_delete','privacy.retention_overwrite_parent'),
('external_days','privacy.retention_external_days'),
('external_manual','privacy.retention_external_manual'),
('external_overwrite_parent_delete','privacy.retention_external_overwrite'),
('not_stored','privacy.retention_not_stored'),
('manual','privacy.retention_manual')
] %}<option value="{{ value }}" {% if category.retention_type == value %}selected{% endif %}>{{ t(key) }}</option>{% endfor %}
</select>
</label>
<label>{{ t('privacy.retention_days') }}<input type="number" min="0" max="36500" name="category_retention_days" value="{{ category.retention_days if category.retention_days is not none else '' }}"></label>
<label>{{ t('privacy.automatic_possible') }}
<select name="category_automatic_possible"><option value="0" {% if not category.automatic_possible %}selected{% endif %}>{{ t('common.no') }}</option><option value="1" {% if category.automatic_possible %}selected{% endif %}>{{ t('common.yes') }}</option></select>
</label>
</div>
<label>{{ t('privacy.retention_trigger') }}<textarea name="category_retention_trigger" rows="2">{{ category.retention_trigger }}</textarea></label>
<label>{{ t('privacy.manual_procedure') }}<textarea name="category_manual_procedure" rows="3">{{ category.manual_procedure }}</textarea></label>
<label>{{ t('privacy.backup_handling') }}<textarea name="category_backup_handling" rows="2">{{ category.backup_handling }}</textarea></label>
<div class="form-grid two-columns">
<label>{{ t('privacy.responsible') }}<input type="text" name="category_responsible" value="{{ category.responsible }}"></label>
<label>{{ t('privacy.notes') }}<textarea name="category_notes" rows="2">{{ category.notes }}</textarea></label>
</div>
<div class="privacy-retention-actions">
{% if category.is_system %}<span class="muted privacy-system-note">🔒 {{ t('privacy.system_category_help') }}</span>{% endif %}
{% if can_edit and category.key in implemented_retention_keys %}
<button type="button" class="button-secondary" data-retention-check-url="/settings/privacy/retention/{{ category.key }}/check">{{ t('privacy.run_check') }}</button>
<button type="button" class="button-danger" data-retention-delete-url="/settings/privacy/retention/{{ category.key }}/delete" data-category-name="{{ category.name }}">{{ t('privacy.run_manual_deletion') }}</button>
{% elif category.automatic_possible %}
<span class="muted">{{ t('privacy.handler_not_implemented') }}</span>
{% endif %}
{% if can_edit and not category.is_system %}<button type="button" class="button-danger" data-remove-category>{{ t('privacy.remove_category') }}</button>{% endif %}
</div>
{% set preview = preview_results.get(category.key) %}
{% if preview %}
<div class="notice info privacy-preview-result">
<strong>{{ t('privacy.check_result') }}</strong>
<span>{{ t('privacy.matched_records') }}: {{ preview.matched_records or 0 }}</span>
<span>{{ t('privacy.approx_size') }}: {{ ((preview.approx_bytes or 0) / 1024 / 1024)|round(2) }} MB</span>
<span>{{ t('privacy.cutoff') }}: {{ preview.cutoff_utc or '-' }}</span>
{% if not preview.supported %}<span>{{ preview.reason }}</span>{% endif %}
</div>
{% endif %}
</section>
{% endfor %}
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.toms') }}</h2><p class="muted">{{ t('privacy.toms_help') }}</p></div>
<button type="button" class="button-secondary" data-add-list-row="toms-list">{{ t('common.add', 'Add') }}</button>
</div>
<div id="toms-list" class="repeatable-list">
{% for value in activity.toms %}
<div class="repeatable-row"><textarea name="toms" rows="2">{{ value }}</textarea><button type="button" class="button-danger button-small" data-remove-row>×</button></div>
{% endfor %}
</div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.review') }}</h2>
<div class="form-grid three-columns">
<label>{{ t('privacy.review_interval_months') }}<input type="number" min="1" max="120" name="review_interval_months" value="{{ activity.review_interval_months }}"></label>
<label>{{ t('privacy.last_reviewed_at') }}<input type="date" name="last_reviewed_at" value="{{ activity.last_reviewed_at }}"></label>
<label>{{ t('privacy.approved_by') }}<input type="text" name="approved_by" value="{{ activity.approved_by }}"></label>
</div>
</section>
{% if can_edit %}<div class="form-actions sticky-form-actions"><button type="submit" class="button-save">{{ t('common.save') }}</button></div>{% endif %}
</form>
<section class="settings-section">
<h2>{{ t('privacy.policy_audit') }}</h2>
<p class="muted">{{ t('privacy.policy_audit_help') }}</p>
<div class="table-wrap"><table class="data-table" data-table-tools="true"><thead><tr><th>{{ t('common.date') }}</th><th>{{ t('privacy.action') }}</th><th>{{ t('privacy.changed_by') }}</th><th>{{ t('privacy.details') }}</th></tr></thead><tbody>
{% for row in privacy_audit %}<tr><td data-local-datetime="{{ row.timestamp_utc }}">{{ row.timestamp_utc }}</td><td>{{ row.action }}</td><td>{{ row.changed_by }}</td><td><code>{{ row.details }}</code></td></tr>{% else %}<tr><td colspan="4" class="muted">{{ t('privacy.no_audit_entries') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.deletion_audit') }}</h2>
<p class="muted">{{ t('privacy.deletion_audit_help') }}</p>
<div class="table-wrap"><table class="data-table" data-table-tools="true"><thead><tr><th>{{ t('common.date') }}</th><th>{{ t('privacy.run_id') }}</th><th>{{ t('privacy.action') }}</th><th>{{ t('privacy.category_name') }}</th><th>{{ t('privacy.changed_by') }}</th><th>{{ t('privacy.details') }}</th></tr></thead><tbody>
{% for row in deletion_audit %}<tr><td data-local-datetime="{{ row.timestamp_utc }}">{{ row.timestamp_utc }}</td><td><code>{{ row.run_id }}</code></td><td>{{ row.action }}</td><td>{{ row.category_key }}</td><td>{{ row.changed_by }}</td><td><code>{{ row.result }}</code></td></tr>{% else %}<tr><td colspan="6" class="muted">{{ t('privacy.no_deletion_entries') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
<template id="privacy-category-template">
<section class="privacy-category-panel" role="tabpanel" hidden data-category-panel>
<input type="hidden" name="category_key" value="">
<div class="form-grid two-columns">
<label>{{ t('privacy.category_name') }}<input type="text" name="category_name" value="" data-category-name></label>
<label>{{ t('privacy.system') }}<input type="text" name="category_system" value=""></label>
</div>
<label>{{ t('privacy.data_subjects') }}<textarea name="category_data_subjects" rows="2"></textarea></label>
<label>{{ t('privacy.personal_data') }}<textarea name="category_personal_data" rows="3"></textarea></label>
<label>{{ t('privacy.purpose') }}<textarea name="category_purpose" rows="2"></textarea></label>
<div class="form-grid three-columns">
<label>{{ t('privacy.retention_type') }}
<select name="category_retention_type">
<option value="days">{{ t('privacy.retention_days_type') }}</option>
<option value="manual_parent_delete">{{ t('privacy.retention_manual_parent') }}</option>
<option value="overwrite_parent_delete">{{ t('privacy.retention_overwrite_parent') }}</option>
<option value="external_days">{{ t('privacy.retention_external_days') }}</option>
<option value="external_manual">{{ t('privacy.retention_external_manual') }}</option>
<option value="external_overwrite_parent_delete">{{ t('privacy.retention_external_overwrite') }}</option>
<option value="not_stored">{{ t('privacy.retention_not_stored') }}</option>
<option value="manual" selected>{{ t('privacy.retention_manual') }}</option>
</select>
</label>
<label>{{ t('privacy.retention_days') }}<input type="number" min="0" max="36500" name="category_retention_days"></label>
<label>{{ t('privacy.automatic_possible') }}<select name="category_automatic_possible"><option value="0">{{ t('common.no') }}</option><option value="1">{{ t('common.yes') }}</option></select></label>
</div>
<label>{{ t('privacy.retention_trigger') }}<textarea name="category_retention_trigger" rows="2"></textarea></label>
<label>{{ t('privacy.manual_procedure') }}<textarea name="category_manual_procedure" rows="3"></textarea></label>
<label>{{ t('privacy.backup_handling') }}<textarea name="category_backup_handling" rows="2"></textarea></label>
<div class="form-grid two-columns"><label>{{ t('privacy.responsible') }}<input type="text" name="category_responsible"></label><label>{{ t('privacy.notes') }}<textarea name="category_notes" rows="2"></textarea></label></div>
<div class="form-actions"><button type="button" class="button-danger" data-remove-category>{{ t('privacy.remove_category') }}</button></div>
</section>
</template>
<script>
(() => {
const privacyForm = document.querySelector('.privacy-settings-form');
const readOnly = privacyForm?.dataset.privacyReadonly === 'true';
if (readOnly) {
privacyForm.querySelectorAll('input, textarea').forEach(control => {
if (control.type === 'hidden') return;
control.readOnly = true;
control.setAttribute('aria-readonly', 'true');
});
privacyForm.querySelectorAll('select').forEach(control => {
control.disabled = true;
});
privacyForm.querySelectorAll('button:not([data-category-tab])').forEach(control => {
control.disabled = true;
});
}
document.addEventListener('click', event => {
const checkButton = event.target.closest('[data-retention-check-url]');
const deleteButton = event.target.closest('[data-retention-delete-url]');
const url = checkButton?.dataset.retentionCheckUrl || deleteButton?.dataset.retentionDeleteUrl;
if (!url) return;
event.preventDefault();
let confirmation = '';
if (deleteButton) {
const categoryName = deleteButton.dataset.categoryName || '';
confirmation = window.prompt({{ t('privacy.delete_confirmation_prompt')|tojson }}.replace('{category}', categoryName), '');
if (confirmation === null) return;
}
const form = document.createElement('form');
form.method = 'post';
form.action = url;
if (deleteButton) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmation';
input.value = confirmation;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
});
const workbook = document.querySelector('[data-category-workbook]');
const tabs = workbook?.querySelector('[data-category-tabs]');
const panels = workbook?.querySelector('[data-category-panels]');
const newCategoryLabel = {{ t('privacy.new_category')|tojson }};
let nextPanelId = document.querySelectorAll('[data-category-panel]').length;
const activate = panel => {
if (!panel || !tabs || !panels) return;
panels.querySelectorAll('[data-category-panel]').forEach(item => {
const active = item === panel;
item.classList.toggle('active', active);
item.hidden = !active;
});
tabs.querySelectorAll('[data-category-tab]').forEach(tab => {
const active = tab.getAttribute('aria-controls') === panel.id;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.tabIndex = active ? 0 : -1;
});
tabs.querySelector(`[aria-controls="${CSS.escape(panel.id)}"]`)?.scrollIntoView({block: 'nearest', inline: 'nearest'});
};
const createTab = (panel, title) => {
if (!tabs) return null;
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'privacy-category-tab';
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('aria-controls', panel.id);
tab.tabIndex = -1;
const label = document.createElement('span');
label.dataset.categoryTabTitle = '';
label.textContent = title || newCategoryLabel;
tab.appendChild(label);
tabs.appendChild(tab);
return tab;
};
const bindPanel = panel => {
if (!panel || panel.dataset.bound === 'true') return;
panel.dataset.bound = 'true';
const nameInput = panel.querySelector('[data-category-name]');
const tab = tabs?.querySelector(`[aria-controls="${CSS.escape(panel.id)}"]`);
nameInput?.addEventListener('input', () => {
const label = tab?.querySelector('[data-category-tab-title]');
if (label) label.textContent = nameInput.value.trim() || newCategoryLabel;
});
panel.querySelector('[data-remove-category]')?.addEventListener('click', () => {
const allPanels = [...panels.querySelectorAll('[data-category-panel]')];
const index = allPanels.indexOf(panel);
const fallback = allPanels[index + 1] || allPanels[index - 1] || null;
tab?.remove();
panel.remove();
if (fallback) activate(fallback);
});
};
tabs?.addEventListener('click', event => {
const tab = event.target.closest('[data-category-tab]');
if (!tab) return;
activate(document.getElementById(tab.getAttribute('aria-controls')));
});
tabs?.addEventListener('keydown', event => {
const tab = event.target.closest('[data-category-tab]');
if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const allTabs = [...tabs.querySelectorAll('[data-category-tab]')];
let index = allTabs.indexOf(tab);
if (event.key === 'ArrowLeft') index = (index - 1 + allTabs.length) % allTabs.length;
if (event.key === 'ArrowRight') index = (index + 1) % allTabs.length;
if (event.key === 'Home') index = 0;
if (event.key === 'End') index = allTabs.length - 1;
event.preventDefault();
allTabs[index]?.focus();
allTabs[index]?.click();
});
document.querySelectorAll('[data-category-panel]').forEach(bindPanel);
const initiallyActive = document.querySelector('[data-category-panel].active') || document.querySelector('[data-category-panel]');
if (initiallyActive) activate(initiallyActive);
const bindRemoveRows = root => root.querySelectorAll('[data-remove-row]').forEach(button => button.onclick = () => button.closest('.repeatable-row')?.remove());
bindRemoveRows(document);
document.querySelectorAll('[data-add-list-row]').forEach(button => button.addEventListener('click', () => {
const list = document.getElementById(button.dataset.addListRow);
if (!list) return;
const name = list.id === 'purposes-list' ? 'purposes' : 'toms';
const row = document.createElement('div');
row.className = 'repeatable-row';
row.innerHTML = name === 'purposes'
? `<input type="text" name="${name}"><button type="button" class="button-danger button-small" data-remove-row>×</button>`
: `<textarea name="${name}" rows="2"></textarea><button type="button" class="button-danger button-small" data-remove-row>×</button>`;
list.appendChild(row);
bindRemoveRows(row);
}));
document.getElementById('add-privacy-category')?.addEventListener('click', () => {
const template = document.getElementById('privacy-category-template');
if (!template || !panels || !tabs) return;
const fragment = template.content.cloneNode(true);
const panel = fragment.querySelector('[data-category-panel]');
panel.id = `privacy-category-panel-${nextPanelId++}`;
panels.appendChild(fragment);
const inserted = panels.lastElementChild;
createTab(inserted, newCategoryLabel);
bindPanel(inserted);
activate(inserted);
inserted.querySelector('[data-category-name]')?.focus();
});
})();
</script>
{% endblock %}