Corp/backend/templates/employees/components/department_scripts.html
Игорь Надеин 026bc46816 feat(department-roles): complete LDAP architecture redesign with RoleAssignment model
BREAKING CHANGES:
- Removed OU=Roles from LDAP structure, role groups now created directly in department OU
- Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_)

New features:
- RoleAssignment model: allows assigning roles to ANY employee, not just department members
- New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet
- Permission system updated: has_dept_perm() checks RoleAssignment first
- Employee profile shows external roles with 'внешняя роль' badge
- Employee list filter includes role-only employees with department_relation field
- Role assignment modal now searches all employees via AJAX

LDAP changes:
- _ensure_role_group(): creates ROLE_<Name> group in department OU
- assign_role(), revoke_role(): manage RoleAssignment + LDAP sync
- rename_role_group(), delete_role_group(): full lifecycle support
- Removed all references to OU=Roles

Migrations:
- 0035: Create RoleAssignment model
- 0036: Data migration from EmployeeDepartment.role

Tests:
- 7 new tests for role assignment (all passing)

Docs:
- 9 detailed reports on architecture, models, API, LDAP, tests
2025-12-30 10:35:34 +03:00

347 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% load static %}
{# ==== JS: круг с автоскроллом/ручным управлением и индикатором ==== #}
<script type="module">
import { initTeamWheel } from '{% static "js/components/teamWheel.js" %}';
initTeamWheel({
wheelId: 'teamWheel-{{ dept.id }}',
dataSourceId: 'teamData-{{ dept.id }}',
fallback: {
avatar: "{{ dept.head.avatar|default:'' }}",
name: "{% if dept.head %}{{ dept.head.display_name|default:dept.head.last_name }}{% else %}Руководитель{% endif %}",
role: "Руководитель отдела",
email: "{% if dept.head and dept.head.email %}{{ dept.head.email }}{% endif %}"
}
});
</script>
{# ===== Переключатель панели настроек (скролл к блоку при открытии) ===== #}
<script type="module">
import { smoothScrollTo } from '{% static "js/utils/scroll.js" %}';
document.addEventListener('DOMContentLoaded', () => {
const wrap = document.getElementById('deptEditWrap-{{ dept.id }}');
if (!wrap) return;
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
function headerOffset(){
const sticky = document.querySelector('.ios-header');
return (sticky?.offsetHeight || 0) + 8;
}
wrap.addEventListener('shown.bs.collapse', () => {
if (isMobile()) smoothScrollTo(wrap, { offset: headerOffset() });
});
if (wrap.classList.contains('show') && isMobile()) {
setTimeout(() => smoothScrollTo(wrap, { offset: headerOffset() }), 50);
}
});
</script>
{# ===== Модал «Добавить участника»: AJAX-поиск ===== #}
<script type="module">
{% url 'employees:employees_list' as employees_list_url %}
import { esc } from '{% static "js/utils/stringUtils.js" %}';
import { debounce } from '{% static "js/utils/timing.js" %}';
(function(){
const modal = document.getElementById('addMemberModal-{{ dept.id }}');
if (!modal) return;
const form = modal.querySelector('form');
const input = modal.querySelector('[data-add-member-q]');
const listEl = modal.querySelector('[data-add-member-list]');
const tpl = modal.querySelector('#addMemberItemTpl-{{ dept.id }}');
const hidden = form ? form.querySelector('input[name="employee_id"]') : null;
const submit = modal.querySelector('[data-add-member-submit]');
if (!form || !input || !listEl || !tpl || !hidden || !submit) return;
const excludeAttr = (modal.getAttribute('data-exclude') || '').trim();
const exclude = new Set(
excludeAttr ? excludeAttr.split(',').map(x => parseInt(x, 10)).filter(n => !Number.isNaN(n)) : []
);
let aborter = null;
async function searchEmployees(q){
if (aborter) aborter.abort();
aborter = new AbortController();
const params = new URLSearchParams({
format: 'json',
q: q,
limit: '20',
exclude: Array.from(exclude).join(',')
});
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
credentials: 'same-origin',
signal: aborter.signal
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json().catch(()=>({results:[]}));
return Array.isArray(data) ? data : (data.results || []);
}
function render(items){
listEl.innerHTML = '';
if (!items.length){
const empty = document.createElement('div');
empty.className = 'list-group-item text-muted small py-3';
empty.textContent = 'Ничего не найдено';
listEl.appendChild(empty);
return;
}
items.forEach(e => {
const node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = e.id;
const ava = node.querySelector('.card-icon');
if (ava){
ava.innerHTML = e.avatar
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
: '<i class="bi-person"></i>';
}
const nameEl = node.querySelector('.js-name');
if (nameEl){
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
}
const emailEl = node.querySelector('.js-email');
if (emailEl) emailEl.textContent = e.email || '';
node.addEventListener('click', () => {
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
node.classList.add('active');
hidden.value = String(e.id);
submit.disabled = false;
submit.removeAttribute('disabled');
});
listEl.appendChild(node);
});
}
const run = debounce(async (q) => {
submit.disabled = true;
hidden.value = '';
try { render(await searchEmployees(q)); }
catch { listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>'; }
}, 200);
input.addEventListener('input', () => run(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter'){
const cur = listEl.querySelector('.list-group-item.active') || listEl.querySelector('.list-group-item');
if (cur && cur.dataset.id){
e.preventDefault();
cur.click();
}
}
});
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
hidden.value = '';
submit.disabled = true;
input.focus();
run('');
});
form.addEventListener('submit', (e) => {
if (!hidden.value){
e.preventDefault();
input.focus();
}
});
})();
</script>
{# ===== Модал «Удалить участника»: локальный выбор ===== #}
<script type="module">
import { norm } from '{% static "js/utils/stringUtils.js" %}';
(function(){
const modal = document.getElementById('removeMemberModal-{{ dept.id }}');
if (!modal) return;
const form = modal.querySelector('form');
const input = document.getElementById('removeMemberSearch-{{ dept.id }}');
const listBox = document.getElementById('removeMemberResults-{{ dept.id }}');
const hiddenEmp = document.getElementById('removeMemberChosen-{{ dept.id }}');
const hiddenLink= document.getElementById('removeMemberLink-{{ dept.id }}');
const submit = document.getElementById('removeMemberSubmit-{{ dept.id }}');
if (!form || !input || !listBox || !hiddenEmp || !hiddenLink || !submit) return;
function clearActive(){ listBox.querySelectorAll('.active').forEach(el => el.classList.remove('active')); }
function matches(item, q){
const name = norm(item.getAttribute('data-name') || '');
const email = norm(item.getAttribute('data-email') || '');
const id = String(item.getAttribute('data-id') || '');
return !q || name.includes(q) || email.includes(q) || id.includes(q);
}
function applyFilter(){
const q = norm(input.value);
listBox.querySelectorAll('.list-group-item, .list-group-item-action').forEach(it => {
const ok = matches(it, q);
it.classList.toggle('d-none', !ok);
});
}
input.addEventListener('input', applyFilter);
listBox.addEventListener('click', (e) => {
const it = e.target.closest('[data-id]');
if (!it) return;
clearActive();
it.classList.add('active');
hiddenEmp.value = it.getAttribute('data-id') || '';
hiddenLink.value = it.getAttribute('data-link-id') || '';
submit.disabled = !(hiddenEmp.value || hiddenLink.value);
if (!submit.disabled) submit.removeAttribute('disabled');
});
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
clearActive();
applyFilter();
hiddenEmp.value = '';
hiddenLink.value = '';
submit.disabled = true;
input.focus();
});
form.addEventListener('submit', (e) => {
if (!hiddenEmp.value && !hiddenLink.value){
e.preventDefault();
input.focus();
}
});
})();
</script>
{# ===== Модал «Назначить роль»: AJAX-поиск по всем сотрудникам ===== #}
<script type="module">
{% url 'employees:employees_list' as employees_list_url %}
import { esc } from '{% static "js/utils/stringUtils.js" %}';
import { debounce } from '{% static "js/utils/timing.js" %}';
(function(){
const modal = document.getElementById('setRoleModal-{{ dept.id }}');
if (!modal) return;
const form = modal.querySelector('form');
const actionTpl = form ? form.getAttribute('data-action-template') : null;
const input = modal.querySelector('[data-set-role-q]');
const listEl = modal.querySelector('[data-set-role-list]');
const tpl = modal.querySelector('#setRoleItemTpl-{{ dept.id }}');
const hidden = document.getElementById('setRoleChosen-{{ dept.id }}');
const select = document.getElementById('setRoleSelect-{{ dept.id }}');
const submit = document.getElementById('setRoleSubmit-{{ dept.id }}');
if (!form || !input || !listEl || !tpl || !hidden || !select || !submit) return;
let aborter = null;
function setAction(empId){
if (!actionTpl) return;
// /employees/0/departments/<deptId>/set-role/ -> /employees/<empId>/departments/<deptId>/set-role/
const newAction = actionTpl.replace(/(\/employees\/)(\d+)(\/)/, '$1' + String(empId) + '$3');
form.setAttribute('action', newAction);
}
async function searchEmployees(q){
if (aborter) aborter.abort();
aborter = new AbortController();
const params = new URLSearchParams({
format: 'json',
q: q,
limit: '20'
});
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
credentials: 'same-origin',
signal: aborter.signal
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json().catch(()=>({results:[]}));
return Array.isArray(data) ? data : (data.results || []);
}
function render(items){
listEl.innerHTML = '';
if (!items.length){
const empty = document.createElement('div');
empty.className = 'list-group-item text-muted small py-3';
empty.textContent = 'Ничего не найдено';
listEl.appendChild(empty);
return;
}
items.forEach(e => {
const node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = e.id;
const ava = node.querySelector('.card-icon');
if (ava){
ava.innerHTML = e.avatar
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
: '<i class="bi-person"></i>';
}
const nameEl = node.querySelector('.js-name');
if (nameEl){
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
}
const emailEl = node.querySelector('.js-email');
if (emailEl) emailEl.textContent = e.email || '';
node.addEventListener('click', () => {
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
node.classList.add('active');
hidden.value = String(e.id);
setAction(e.id);
submit.disabled = false;
submit.removeAttribute('disabled');
});
listEl.appendChild(node);
});
}
const run = debounce(async (q) => {
submit.disabled = true;
hidden.value = '';
if (!q || q.length < 2) {
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Введите минимум 2 символа для поиска…</div>';
return;
}
listEl.innerHTML = '<div class="list-group-item text-muted small py-3"><span class="spinner-border spinner-border-sm me-2"></span>Поиск...</div>';
try { render(await searchEmployees(q)); }
catch (err) {
if (err.name !== 'AbortError') {
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>';
}
}
}, 300);
input.addEventListener('input', () => run(input.value.trim()));
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>';
hidden.value = '';
submit.disabled = true;
input.focus();
if (actionTpl) form.setAttribute('action', actionTpl);
});
form.addEventListener('submit', (e) => {
if (!hidden.value){
e.preventDefault();
input.focus();
}
});
})();
</script>
<script type="module">
import { initTooltips } from "{% static 'js/utils/tooltipInitializer.js' %}";
initTooltips();
</script>