мелкая доработка шаблонов

This commit is contained in:
admin i 2025-09-02 12:33:33 +03:00
parent 016ee1ef50
commit 0674bd49e6
3 changed files with 178 additions and 56 deletions

View File

@ -563,7 +563,7 @@
<!-- RIGHT -->
<div class="col-md-8">
{% if can_edit %}
<div id="editFormWrap" class="collapse">
<div id="editFormWrap" class="collapse mb-4">
<article class="ios-card">
<div class="p-4">
<div class="d-flex align-items-center gap-2">
@ -712,6 +712,43 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const wrap = document.getElementById('editFormWrap');
if (!wrap) return;
// определяем, мобильный ли экран
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
// вычисляем отступ под фикс/стикки заголовок
function getTopOffset(){
const sticky = document.querySelector('.ios-header'); // твой page header
return (sticky?.offsetHeight || 0) + 8; // + маленький зазор
}
function smoothScrollTo(el){
const top = el.getBoundingClientRect().top + window.pageYOffset - getTopOffset();
// если поддерживается smooth
try {
window.scrollTo({ top, behavior: 'smooth' });
} catch {
window.scrollTo(0, top);
}
}
// когда коллапс раскрылся — скроллим к нему (только на мобилках)
wrap.addEventListener('shown.bs.collapse', () => {
if (isMobile()) smoothScrollTo(wrap);
});
// если блок уже открыт при загрузке (например, по якорю) — тоже скроллим
if (wrap.classList.contains('show') && isMobile()) {
// небольшая задержка, чтобы страница «устаканилась»
setTimeout(() => smoothScrollTo(wrap), 50);
}
});
</script>
{% endblock %}

View File

@ -31,18 +31,88 @@
}
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
.feed-header { gap:.6rem; flex-wrap:wrap; }
.alpha-header{
position:sticky; top:calc(var(--navbar-h,0px) + 6px);
z-index:1; background: color-mix(in srgb, var(--bs-body-bg) 92%, transparent);
padding:.25rem .5rem; margin:.75rem 0 .25rem;
font-weight:800; letter-spacing:.02em; border-radius:10px; width:max-content;
}
.alpha-dot{ display:inline-block; width:.4rem; height:.4rem; border-radius:50%; background:var(--bs-primary); margin-right:.4rem; }
.feed-card.emp-row .feed-ava{ width:56px; height:56px; }
.badge-dept{ background:var(--bs-secondary-bg); color:var(--bs-body-color); border:1px solid var(--bs-border-color); }
</style>
<style>
:root{
--emp-avatar: 56px;
--emp-gap: 14px;
}
/* общий блок-«карточка» со скроллом */
#empList{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
border-radius: var(--feed-radius, 16px);
background: var(--bs-body-bg);
overflow:hidden;
padding:0;
max-height: calc(100vh - var(--navbar-h, 0px) - 160px);
overflow-y:auto;
-webkit-overflow-scrolling: touch;
}
/* строка сотрудника */
#empList .emp-row{
padding:.6rem .75rem !important;
border-bottom:1px solid var(--bs-border-color);
}
#empList .emp-row:last-child{ border-bottom:0; }
/* раскладка: [аватар] [контент] [действия] */
#empList .emp-row .feed-hd{
display:grid;
grid-template-columns: var(--emp-avatar) 1fr auto;
align-items:center;
gap: var(--emp-gap);
margin:0; padding:0;
}
/* аватар: круг, центрирование, одинаковое поведение img и иконки */
#empList .emp-row .feed-ava{
width: var(--emp-avatar);
height: var(--emp-avatar); /* вместо aspect-ratio */
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%);
/* На случай агрессивных глобальных правил */
flex: none; /* grid не тронет размер ячейки */
}
#empList .emp-row .feed-ava img{
display: block; /* убираем baseline-глюк */
width: 100%;
height: 100%; /* принудительно квадратная вписка */
object-fit: cover; /* не тянем пропорции */
object-position: center;
}
#empList .emp-row .feed-ava i{
font-size: 1.5rem;
line-height: 1;
opacity: .6;
}
/* контент */
#empList .emp-row .feed-meta{
margin:0; padding:0; min-width:0;
}
.feed-author{ font-weight:700; line-height:1.25; }
.emp-pos{ color:var(--bs-secondary-color); }
.feed-sub{
margin-top:.25rem; color:var(--bs-secondary-color);
}
.feed-sub a{ color:inherit; text-decoration:none; }
.feed-sub a:hover{ text-decoration:underline; }
/* действия: в правый край колонки */
.emp-actions{ justify-self:end; }
</style>
{% endblock %}
{% block content %}
@ -65,7 +135,7 @@
<div class="ms-auto ios-search">
<i class="bi-search"></i>
<input id="empFilter" type="search" class="ios-search-input"
placeholder="Поиск по ФИО" autocomplete="off">
placeholder="Поиск по ФИО, должности или отделу" autocomplete="off">
<button type="button" class="ios-search-clear" aria-label="Очистить">
<i class="bi-x-lg"></i>
</button>
@ -75,19 +145,9 @@
<div id="empList" class="feed-stream mt-3">
{% if employees %}
{% for emp in employees %}
{# буква-секция по первой букве фамилии (или имени, если нет фамилии) #}
{% with lname=emp.last_name|default_if_none:"" fname=emp.first_name|default_if_none:"" %}
{% with base=lname|default:fname %}
{% with letter=base|default:"?"|slice:":1"|upper %}
{% ifchanged letter %}
<div class="alpha-header"><span class="alpha-dot"></span>{{ letter }}</div>
{% endifchanged %}
{% endwith %}
{% endwith %}
{% endwith %}
<article class="feed-card emp-row"
data-name="{{ emp.last_name }} {{ emp.first_name }} {{ emp.patronymic }}">
<article class="emp-row"
data-name="{{ emp.last_name }} {{ emp.first_name }} {{ emp.patronymic }} {% firstof emp.position.name emp.position_label '' %}"
data-depts="{% if emp.departments %}{% for d in emp.departments %}{% if d.name %}{{ d.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %}{% elif is_filtered_by_department and current_department %}{{ current_department.name }}{% endif %}">
<header class="feed-hd">
<div class="feed-ava">
{% if emp.avatar %}
@ -100,8 +160,7 @@
<div class="feed-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if emp.id %}
<a href="{% url 'employees:employee_detail' emp.id %}"
class="feed-author text-decoration-none">
<a href="{% url 'employees:employee_detail' emp.id %}" class="feed-author text-decoration-none">
{{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
</a>
{% else %}
@ -109,23 +168,31 @@
{{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
</span>
{% endif %}
{% if emp.position and emp.position.name %}
<span class="emp-pos">— {{ emp.position.name }}</span>
{% elif emp.position_label %}
<span class="emp-pos">— {{ emp.position_label }}</span>
{% elif emp.position %}
<span class="emp-pos">— Должность #{{ emp.position }}</span>
{% endif %}
</div>
{# бейджи отделов (если бэкенд вернул emp.departments как список словарей) #}
{% if emp.departments %}
<div class="feed-sub mt-2 d-flex flex-wrap gap-2">
{% for d in emp.departments|slice:":3" %}
<span class="badge badge-dept">{{ d.name }}</span>
<div class="feed-sub">
{% for d in emp.departments %}
{% if d.name %}
{% if d.id %}
<a href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
{% else %}
{{ d.name }}
{% endif %}
{% if not forloop.last %}, {% endif %}
{% endif %}
{% endfor %}
{% if emp.departments|length > 3 %}
<span class="badge badge-dept">+{{ emp.departments|length|add:"-3" }}</span>
{% endif %}
</div>
{% elif is_filtered_by_department and current_department %}
{# если списка отделов нет, но мы уже отфильтрованы по отделу — покажем его #}
<div class="feed-sub mt-2">
<span class="badge badge-dept">{{ current_department.name }}</span>
</div>
<div class="feed-sub">{{ current_department.name }}</div>
{% endif %}
</div>
@ -145,7 +212,7 @@
</div>
</header>
<div class="feed-body pt-0"></div>
<div class="feed-body"></div>
</article>
{% endfor %}
{% else %}
@ -153,7 +220,6 @@
{% endif %}
</div>
{# Пагинация, если фронт-вьюха передала номера страниц #}
{% if next_page or prev_page %}
<nav class="mt-3">
<ul class="pagination">
@ -175,22 +241,18 @@
(function () {
const input = document.getElementById('empFilter');
const clearBtn = document.querySelector('.ios-search-clear');
const rows = () => document.querySelectorAll('#empList .emp-row');
const list = document.getElementById('empList');
function applyFilter(){
const q = (input?.value || '').trim().toLowerCase();
rows().forEach(row => {
const hay = (row.getAttribute('data-name') || '').toLowerCase();
list.querySelectorAll('.emp-row').forEach(row => {
const hay = [(row.dataset.name||''), (row.dataset.depts||'')].join(' ').toLowerCase();
row.style.display = (!q || hay.includes(q)) ? '' : 'none';
});
}
input?.addEventListener('input', applyFilter);
clearBtn?.addEventListener('click', () => {
input.value = '';
input.focus();
input.dispatchEvent(new Event('input'));
});
clearBtn?.addEventListener('click', () => { input.value=''; input.focus(); input.dispatchEvent(new Event('input')); });
applyFilter();
})();
</script>
{% endblock %}

View File

@ -9,21 +9,28 @@ from employees.models import Employee, Department, DepartmentRole, EmployeeDepar
# --- helpers ---
_seq = 1
def _unique_email(prefix="user"):
global _seq
_seq += 1
return f"{prefix}{_seq}@example.com"
def _unique_phone():
global _seq
_seq += 1
return f"+7999{_seq:07d}"
def _make_user(staff=False, superuser=False) -> Employee:
u = Employee.objects.create_user(
email=_unique_email(), password="pass",
email=_unique_email(),
password="pass",
phone_number=_unique_phone(),
send_activation_email=False, first_name="T", last_name="U",
send_activation_email=False,
first_name="T",
last_name="U",
)
u.is_staff = staff
u.is_superuser = superuser
@ -32,13 +39,16 @@ def _make_user(staff=False, superuser=False) -> Employee:
u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"])
return u
def _dept_set_head(api_client, dept_id, head_id):
url = reverse("api:v1:departments-set-head", args=[dept_id])
payload = {"head_id": head_id} if head_id is not None else {"head_id": None}
return api_client.post(url, payload, format="json")
# --- tests ---
@pytest.mark.django_db
def test_old_head_loses_rights_after_change(api_client):
# setup: old head
@ -61,6 +71,7 @@ def test_old_head_loses_rights_after_change(api_client):
resp = api_client.patch(url_detail, {"description": "should fail"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_new_head_gets_rights_and_membership_created(api_client):
# setup: без главы
@ -84,10 +95,13 @@ def test_new_head_gets_rights_and_membership_created(api_client):
assert resp.status_code == 200
# И ему создана активная связь членства
link = EmployeeDepartment.objects.filter(employee=new_head, department=dept, is_active=True).first()
link = EmployeeDepartment.objects.filter(
employee=new_head, department=dept, is_active=True
).first()
assert link is not None
assert link.date_from is not None
@pytest.mark.django_db
def test_remove_head_deactivates_membership_and_revokes_rights(api_client):
# setup: есть глава
@ -98,7 +112,10 @@ def test_remove_head_deactivates_membership_and_revokes_rights(api_client):
# как глава он может управлять
api_client.force_authenticate(user=head)
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
assert api_client.patch(url_detail, {"description": "ok"}, format="json").status_code == 200
assert (
api_client.patch(url_detail, {"description": "ok"}, format="json").status_code
== 200
)
# staff снимает главу (head -> null)
api_client.force_authenticate(user=staff)
@ -116,6 +133,7 @@ def test_remove_head_deactivates_membership_and_revokes_rights(api_client):
resp = api_client.patch(url_detail, {"description": "nope"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_old_head_keeps_rights_if_role_grants_after_change(api_client):
# setup: старый глава
@ -124,10 +142,14 @@ def test_old_head_keeps_rights_if_role_grants_after_change(api_client):
# подготовим роль отдела с manage_department и назначим её старому главе (как обычному члену)
role = DepartmentRole.objects.create(department=dept, name="ManagerRole")
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
p_manage = Permission.objects.get(
content_type__app_label="employees", codename="manage_department"
)
role.permissions.add(p_manage)
EmployeeDepartment.objects.update_or_create(
employee=old_head, department=dept, defaults={"role": role, "is_active": True, "date_from": timezone.now().date()}
employee=old_head,
department=dept,
defaults={"role": role, "is_active": True, "date_from": timezone.now().date()},
)
# меняем главу
@ -142,6 +164,7 @@ def test_old_head_keeps_rights_if_role_grants_after_change(api_client):
resp = api_client.patch(url_detail, {"description": "still ok"}, format="json")
assert resp.status_code == 200
@pytest.mark.django_db
def test_change_head_does_not_deactivate_old_membership_by_default(api_client):
# при смене head -> старый head остаётся активным членом (согласно текущей логике save())