diff --git a/.gitignore b/.gitignore index 2db7688..3671433 100644 --- a/.gitignore +++ b/.gitignore @@ -149,4 +149,6 @@ media/ packets.txt # Claude Code -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +# Шаблон-референс дизайна (не часть Django-проекта) +ACORE-template/ diff --git a/acore/__init__.py b/acore/__init__.py index e69de29..fb989c4 100644 --- a/acore/__init__.py +++ b/acore/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/acore/celery.py b/acore/celery.py new file mode 100644 index 0000000..7dd24ee --- /dev/null +++ b/acore/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acore.settings.local') + +app = Celery('acore') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/acore/settings/base.py b/acore/settings/base.py index 7998215..31e3da3 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -415,4 +415,10 @@ LOGGING = { 'propagate': False, }, }, -} \ No newline at end of file +} +# --- Celery --- +CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default=CELERY_BROKER_URL) +CELERY_TIMEZONE = TIME_ZONE +# Без брокера (локальная разработка) задачи выполняются синхронно +CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=False) diff --git a/acore/settings/local.py b/acore/settings/local.py index d107c4b..727d4ab 100644 --- a/acore/settings/local.py +++ b/acore/settings/local.py @@ -8,3 +8,7 @@ ALLOWED_HOSTS = ['*'] # INSTALLED_APPS += ['debug_toolbar'] # MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] # INTERNAL_IPS = ['127.0.0.1'] + +# Локально брокер обычно не запущен — задачи выполняются синхронно. +# Для работы с реальным Redis/RabbitMQ поставьте CELERY_TASK_ALWAYS_EAGER=False в .env +CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=True) diff --git a/acore/urls.py b/acore/urls.py index 9d0fb34..9fa4600 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -40,6 +40,17 @@ urlpatterns = [ path('recruiting/', views.recruiting_view, name='recruiting'), path('structure/', views.structure_view, name='structure'), path('settings/', views.settings_view, name='settings'), + path('news/', views.news_view, name='news'), + path('tasks/', views.tasks_view, name='tasks'), + path('tasks/detail/', views.task_detail_view, name='task_detail'), + path('email/', views.email_view, name='email'), + path('chat/', views.chat_view, name='chat'), + path('databases/', views.databases_view, name='databases'), + path('purchases/', views.purchases_view, name='purchases'), + path('hr-portal/', views.hr_portal_view, name='hr_portal'), + path('help/', views.help_view, name='help'), + path('it-support/', views.it_support_view, name='it_support'), + path('pharmacies/detail/', views.pharmacy_detail_view, name='pharmacy_detail'), path('modal-add-employee/', views.modal_add_employee_view, name='modal_add_employee'), ] diff --git a/apps/ad_connector/signals.py b/apps/ad_connector/signals.py index cab1f38..d3ad898 100644 --- a/apps/ad_connector/signals.py +++ b/apps/ad_connector/signals.py @@ -21,6 +21,15 @@ def _is_office(instance) -> bool: return instance.user_location_type == 'office' +def _queue(task, employee, action: str) -> None: + """Ставим Celery-задачу; недоступный брокер не должен ронять сохранение.""" + try: + task.delay(employee.pk) + logger.info(f"Поставлена задача {action} AD-аккаунта для {employee.email}") + except Exception as e: + logger.error(f"Не удалось поставить задачу {action} AD для {employee.email}: {e}") + + @receiver(pre_save, sender=Employee) def cache_employee_ad_state(sender, instance, **kwargs): """Кешируем состояние до сохранения чтобы в post_save можно было сравнить.""" @@ -61,8 +70,7 @@ def sync_new_employee_to_ad(sender, instance, created, **kwargs): return from apps.ad_connector.tasks import sync_employee_to_ad_task - sync_employee_to_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача создания AD-аккаунта для {instance.email}") + _queue(sync_employee_to_ad_task, instance, 'создания') @receiver(post_save, sender=Employee) @@ -84,8 +92,7 @@ def update_employee_in_ad(sender, instance, created, **kwargs): return from apps.ad_connector.tasks import sync_employee_to_ad_task - sync_employee_to_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача обновления AD-аккаунта для {instance.email}") + _queue(sync_employee_to_ad_task, instance, 'обновления') @receiver(post_save, sender=Employee) @@ -97,8 +104,7 @@ def handle_location_change(sender, instance, created, **kwargs): old_location = getattr(instance, '_ad_pre_save_location_type', None) if old_location == 'office' and not _is_office(instance): from apps.ad_connector.tasks import deactivate_employee_in_ad_task - deactivate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Сотрудник {instance.email} переведён из офиса — AD-аккаунт будет отключён") + _queue(deactivate_employee_in_ad_task, instance, 'отключения (перевод из офиса)') @receiver(post_save, sender=Employee) @@ -130,10 +136,8 @@ def toggle_employee_in_ad(sender, instance, created, **kwargs): old_active = getattr(instance, '_ad_pre_save_is_active', None) if old_active is True and not instance.is_active: from apps.ad_connector.tasks import deactivate_employee_in_ad_task - deactivate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача отключения AD-аккаунта для {instance.email}") + _queue(deactivate_employee_in_ad_task, instance, 'отключения') elif (old_active is False and instance.is_active and instance.ad_sync_enabled and _is_office(instance)): from apps.ad_connector.tasks import activate_employee_in_ad_task - activate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача включения AD-аккаунта для {instance.email}") + _queue(activate_employee_in_ad_task, instance, 'включения') diff --git a/apps/ad_connector/tests.py b/apps/ad_connector/tests.py index 7ce503c..dab647f 100644 --- a/apps/ad_connector/tests.py +++ b/apps/ad_connector/tests.py @@ -1,3 +1,367 @@ -from django.test import TestCase +""" +Тесты AD-коннектора: LDAP-клиент (на мок-каталоге ldap3), логика синхронизации, +сигналы и страница сопоставления. +""" +from datetime import date +from unittest import mock -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server + +from apps.ad_connector import views +from apps.ad_connector.ad_service import ADService +from apps.ad_connector.services import sync_service + +Employee = get_user_model() + +BASE_DN = 'DC=company,DC=local' +USERS_OU = 'OU=Users,DC=company,DC=local' + + +def make_mock_ad(use_ssl=True): + """ADService поверх мок-каталога ldap3 (без реального подключения)""" + svc = ADService.__new__(ADService) + svc.base_dn = BASE_DN + svc.users_ou = USERS_OU + svc.domain = 'company.local' + svc.use_ssl = use_ssl + server = Server('mock', get_info=OFFLINE_AD_2012_R2) + conn = Connection(server, client_strategy=MOCK_SYNC, raise_exceptions=True) + conn.strategy.add_entry(BASE_DN, {'objectClass': ['domain'], 'dc': 'company'}) + conn.strategy.add_entry(USERS_OU, {'objectClass': ['organizationalUnit'], 'ou': 'Users'}) + conn.bind() + svc.conn = conn + + # у MOCK_SYNC нет microsoft-расширения смены пароля — подменяем и записываем вызовы + passwords = [] + + class _Microsoft: + modify_password = staticmethod(lambda dn, pw: passwords.append((dn, pw)) or True) + + class _Extend: + microsoft = _Microsoft() + + svc.conn.extend = _Extend() + svc.set_passwords = passwords + svc.unbind = lambda: None + return svc + + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (чтобы сигналы не звали Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'test.user{_employee_seq}', + 'email': f'test.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +class ADServiceMockTests(TestCase): + def setUp(self): + self.svc = make_mock_ad() + + def _create_ivanov(self): + self.svc.create_user(f'CN=Іванов Іван,{USERS_OU}', { + 'sAMAccountName': 'ivanov.i', + 'mail': 'ivanov@company.local', + 'displayName': 'Іванов Іван', + 'givenName': 'Іван', 'sn': 'Іванов', + 'title': 'Фармацевт', + }) + + def test_create_user_is_disabled_by_default(self): + self._create_ivanov() + user = self.svc.get_user_by_sam_account_name('ivanov.i') + self.assertIsNotNone(user) + self.assertTrue(ADService.is_disabled(user)) + + def test_search_by_sam_email_and_dn(self): + self._create_ivanov() + self.assertIsNotNone(self.svc.get_user_by_sam_account_name('ivanov.i')) + self.assertIsNotNone(self.svc.get_user_by_email('ivanov@company.local')) + self.assertIsNotNone(self.svc.get_user_by_dn(f'CN=Іванов Іван,{USERS_OU}')) + self.assertIsNone(self.svc.get_user_by_dn(f'CN=Нема Такого,{USERS_OU}')) + + def test_filter_injection_is_escaped(self): + self._create_ivanov() + self.assertIsNone(self.svc.get_user_by_sam_account_name('a)(mail=*')) + + def test_update_user_empty_value_removes_attribute(self): + self._create_ivanov() + dn = f'CN=Іванов Іван,{USERS_OU}' + self.svc.update_user(dn, {'title': 'Провізор', 'mail': ''}) + user = self.svc.get_user_by_dn(dn) + self.assertEqual(user.get('title'), 'Провізор') + self.assertFalse(user.get('mail')) + + def test_enable_and_disable_user(self): + self._create_ivanov() + dn = f'CN=Іванов Іван,{USERS_OU}' + self.svc.enable_user(dn) + self.assertFalse(ADService.is_disabled(self.svc.get_user_by_dn(dn))) + self.svc.disable_user(dn) + self.assertTrue(ADService.is_disabled(self.svc.get_user_by_dn(dn))) + + def test_rename_user(self): + self._create_ivanov() + old_dn = f'CN=Іванов Іван,{USERS_OU}' + new_dn = self.svc.rename_user(old_dn, 'Петренко Іван') + self.assertEqual(new_dn, f'CN=Петренко Іван,{USERS_OU}') + self.assertIsNotNone(self.svc.get_user_by_dn(new_dn)) + self.assertIsNone(self.svc.get_user_by_dn(old_dn)) + + def test_get_all_users(self): + self._create_ivanov() + users = self.svc.get_all_users() + self.assertEqual(len(users), 1) + self.assertEqual(users[0]['sAMAccountName'], 'ivanov.i') + + +class SyncEmployeeToADTests(TestCase): + def setUp(self): + self.svc = make_mock_ad() + patcher = mock.patch.object(sync_service, 'ADService', lambda: self.svc) + patcher.start() + self.addCleanup(patcher.stop) + + def test_creates_enabled_user_with_password(self): + employee = make_employee(first_name='Анна', last_name='Сидорова') + result = sync_service.sync_employee_to_ad(employee, password='Secret123') + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'synced') + self.assertTrue(employee.ad_user_dn.startswith('CN=')) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertFalse(ADService.is_disabled(user)) + self.assertEqual(self.svc.set_passwords[-1][1], 'Secret123') + + def test_non_office_employee_is_skipped(self): + employee = make_employee(user_location_type='pharmacy') + result = sync_service.sync_employee_to_ad(employee) + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'disabled') + self.assertEqual(employee.ad_user_dn, '') + + def test_update_renames_on_name_change(self): + employee = make_employee(first_name='Анна', last_name='Сидорова') + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + old_dn = employee.ad_user_dn + + employee.last_name = 'Ковальська' + employee.save() + result = sync_service.sync_employee_to_ad(employee) + self.assertTrue(result) + employee.refresh_from_db() + self.assertNotEqual(employee.ad_user_dn, old_dn) + self.assertIn('Ковальська', employee.ad_user_dn) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertEqual(user.get('sn'), 'Ковальська') + self.assertIsNone(self.svc.get_user_by_dn(old_dn)) + + def test_recovers_lost_dn_by_sam_account_name(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + real_dn = employee.ad_user_dn + + Employee.objects.filter(pk=employee.pk).update( + ad_user_dn=f'CN=Загублений,{USERS_OU}', + ) + employee.refresh_from_db() + result = sync_service.sync_employee_to_ad(employee) + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, real_dn) + + def test_update_reenables_disabled_account(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + self.svc.disable_user(employee.ad_user_dn) + + sync_service.sync_employee_to_ad(employee) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertFalse(ADService.is_disabled(user)) + + def test_deactivate_and_activate(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + + self.assertTrue(sync_service.deactivate_ad_user(employee)) + self.assertTrue(ADService.is_disabled(self.svc.get_user_by_dn(employee.ad_user_dn))) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'disabled') + + self.assertTrue(sync_service.activate_ad_user(employee)) + self.assertFalse(ADService.is_disabled(self.svc.get_user_by_dn(employee.ad_user_dn))) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'synced') + + def test_sync_password_requires_linked_account(self): + employee = make_employee() + self.assertFalse(sync_service.sync_ad_password(employee, 'NewPass1')) + + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + self.assertTrue(sync_service.sync_ad_password(employee, 'NewPass1')) + self.assertEqual(self.svc.set_passwords[-1][1], 'NewPass1') + + def test_generate_sam_account_name_limits_and_sanitizes(self): + employee = make_employee(username='Ivanov Ivan+Petrovich-Very-Long-Name') + sam = sync_service.generate_sam_account_name(employee) + self.assertLessEqual(len(sam), 20) + self.assertNotIn(' ', sam) + self.assertNotIn('+', sam) + + def test_generate_random_password_meets_complexity(self): + for _ in range(5): + password = sync_service.generate_random_password() + self.assertEqual(len(password), 16) + self.assertTrue(any(c.islower() for c in password)) + self.assertTrue(any(c.isupper() for c in password)) + self.assertTrue(any(c.isdigit() for c in password)) + + +def make_synced_employee(**extra): + """Сотрудник с включённой AD-синхронизацией и привязанным DN. + Флаги ставятся через update() — сигнал создания не уходит в Celery.""" + employee = make_employee() + fields = {'ad_sync_enabled': True, 'ad_user_dn': f'CN=X,{USERS_OU}'} + fields.update(extra) + Employee.objects.filter(pk=employee.pk).update(**fields) + employee.refresh_from_db() + return employee + + +class ADSignalTests(TestCase): + def test_create_office_employee_with_sync_queues_task(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee = make_employee(ad_sync_enabled=True) + delay.assert_called_once_with(employee.pk) + + def test_create_non_office_employee_does_not_queue(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + make_employee(ad_sync_enabled=True, user_location_type='pharmacy') + delay.assert_not_called() + + def test_create_without_sync_flag_does_not_queue(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + make_employee(ad_sync_enabled=False) + delay.assert_not_called() + + def test_attribute_change_queues_update(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee.first_name = 'Петро' + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_save_without_changes_does_not_queue(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee.save() + delay.assert_not_called() + + def test_transfer_out_of_office_queues_deactivation(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.deactivate_employee_in_ad_task.delay') as delay: + employee.user_location_type = 'pharmacy' + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_block_employee_queues_deactivation(self): + employee = make_employee(ad_user_dn=f'CN=X,{USERS_OU}') + with mock.patch('apps.ad_connector.tasks.deactivate_employee_in_ad_task.delay') as delay: + employee.is_active = False + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_unblock_office_employee_queues_activation(self): + employee = make_synced_employee(is_active=False) + with mock.patch('apps.ad_connector.tasks.activate_employee_in_ad_task.delay') as delay: + employee.is_active = True + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_password_change_syncs_to_ad(self): + employee = make_synced_employee() + with mock.patch( + 'apps.ad_connector.services.sync_service.sync_ad_password', return_value=True, + ) as sync_pw: + employee.set_password('NewSecret1') + employee.save() + sync_pw.assert_called_once() + self.assertEqual(sync_pw.call_args.args[1], 'NewSecret1') + + +class ADMappingViewTests(TestCase): + def setUp(self): + self.staff = make_employee(is_staff=True) + self.svc = make_mock_ad() + patcher = mock.patch.object(views, 'ADService', lambda: self.svc) + patcher.start() + self.addCleanup(patcher.stop) + + def test_requires_staff(self): + regular = make_employee() + self.client.force_login(regular) + response = self.client.get('/ad-mapping/') + self.assertEqual(response.status_code, 302) + + def test_page_renders_with_ad_users(self): + self.svc.create_user(f'CN=Вільний Аккаунт,{USERS_OU}', { + 'sAMAccountName': 'free.account', 'displayName': 'Вільний Аккаунт', + }) + self.client.force_login(self.staff) + response = self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'free.account') + + def test_link_action_sets_dn_and_queues_sync(self): + employee = make_employee() + dn = f'CN=Вільний Аккаунт,{USERS_OU}' + self.svc.create_user(dn, {'sAMAccountName': 'free.account'}) + self.client.force_login(self.staff) + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + response = self.client.post('/ad-mapping/', { + 'action': 'link', 'employee_id': employee.pk, 'dn': dn, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, dn) + delay.assert_called_once_with(employee.pk) + + def test_unlink_action_clears_dn(self): + employee = make_employee(ad_user_dn=f'CN=X,{USERS_OU}', ad_sync_status='synced') + self.client.force_login(self.staff) + response = self.client.post('/ad-mapping/', { + 'action': 'unlink', 'employee_id': employee.pk, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, '') + self.assertEqual(employee.ad_sync_status, 'not_synced') + + def test_action_on_non_office_employee_returns_404(self): + employee = make_employee(user_location_type='pharmacy') + self.client.force_login(self.staff) + response = self.client.post('/ad-mapping/', { + 'action': 'unlink', 'employee_id': employee.pk, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 404) diff --git a/apps/mattermost_connector/signals.py b/apps/mattermost_connector/signals.py index 0b85097..8e358dc 100755 --- a/apps/mattermost_connector/signals.py +++ b/apps/mattermost_connector/signals.py @@ -36,8 +36,12 @@ def sync_new_employee_to_mattermost(sender, instance, created, **kwargs): return from apps.mattermost_connector.tasks import sync_employee_to_mattermost - sync_employee_to_mattermost.delay(instance.pk) - logger.info(f"Поставлена задача создания Mattermost-аккаунта для {instance.email}") + try: + sync_employee_to_mattermost.delay(instance.pk) + logger.info(f"Поставлена задача создания Mattermost-аккаунта для {instance.email}") + except Exception as e: + # Недоступный брокер не должен ронять сохранение сотрудника + logger.error(f"Не удалось поставить задачу Mattermost для {instance.email}: {e}") @receiver(post_save, sender=Employee) diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index de8bdc0..e8602c4 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -1,3 +1,215 @@ -from django.test import TestCase - -# Create your tests here. +""" +Тесты Mattermost-коннектора: генерация учётных данных и логика синхронизации +(MattermostService замокан — сеть не используется). +""" +from datetime import date +from unittest import mock + +from django.contrib.auth import get_user_model +from django.test import TestCase +from mattermostautodriver.exceptions import ResourceNotFound + +from apps.mattermost_connector.models import MattermostUser +from apps.mattermost_connector.services import sync_service + +Employee = get_user_model() + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'mm.user{_employee_seq}', + 'email': f'mm.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +def not_found(): + return ResourceNotFound('not found', 'store.sql_user.missing', 'rid', False) + + +class GenerateUsernameTests(TestCase): + def test_uses_existing_username(self): + employee = make_employee(username='petrov.p') + self.assertEqual(sync_service.generate_username(employee), 'petrov.p') + + def test_generates_from_email_when_no_username(self): + employee = make_employee() + employee.username = '' + employee.email = 'Ivan+Petrov@company.local' + username = sync_service.generate_username(employee) + self.assertEqual(username, 'ivan_petrov') + + +class GenerateRandomPasswordTests(TestCase): + def test_default_length(self): + self.assertEqual(len(sync_service.generate_random_password()), 16) + + def test_custom_length(self): + self.assertEqual(len(sync_service.generate_random_password(24)), 24) + + +class SyncEmployeeTests(TestCase): + """sync_employee с замоканным MattermostService""" + + def setUp(self): + self.service = mock.MagicMock() + patcher = mock.patch.object( + sync_service, 'MattermostService', return_value=self.service, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_finds_existing_user_by_email(self): + employee = make_employee() + self.service.get_user_by_email.return_value = { + 'id': 'mm-abc', 'username': 'found.user', + 'email': employee.email, 'delete_at': 0, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-abc') + self.assertEqual(employee.mattermost_username, 'found.user') + self.assertEqual(employee.mattermost_sync_status, 'synced') + self.assertTrue(MattermostUser.objects.filter(mattermost_id='mm-abc').exists()) + # существующего обновляем, нового не создаём + self.service.update_user.assert_called_once() + self.service.create_user.assert_not_called() + + def test_creates_new_user_when_not_found(self): + employee = make_employee(username='new.user') + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.return_value = { + 'id': 'mm-new', 'username': 'new.user', 'email': employee.email, + } + + result = sync_service.sync_employee(employee, password='Secret123') + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-new') + self.assertEqual(employee.mattermost_sync_status, 'synced') + user_data = self.service.create_user.call_args.args[0] + self.assertEqual(user_data['username'], 'new.user') + self.assertEqual(user_data['password'], 'Secret123') + self.assertEqual(user_data['email'], employee.email) + + def test_username_conflict_appends_counter(self): + employee = make_employee(username='taken.name') + self.service.get_user_by_email.side_effect = not_found() + # первый username занят, второй свободен + self.service.get_user_by_username.side_effect = [ + {'id': 'mm-other', 'username': 'taken.name'}, + not_found(), + ] + self.service.create_user.return_value = { + 'id': 'mm-new2', 'username': 'taken.name1', 'email': employee.email, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + user_data = self.service.create_user.call_args.args[0] + self.assertEqual(user_data['username'], 'taken.name1') + + def test_username_check_does_not_swallow_network_errors(self): + """Сетевая ошибка при проверке username — это ошибка синхронизации, + а не сигнал «имя свободно» (регресс голого except).""" + employee = make_employee() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = OSError('connection refused') + + result = sync_service.sync_employee(employee) + + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_sync_status, 'error') + self.service.create_user.assert_not_called() + + def test_stale_mattermost_id_is_reset_and_user_recreated(self): + employee = make_employee(mattermost_user_id='mm-stale') + self.service.get_user.side_effect = not_found() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.return_value = { + 'id': 'mm-fresh', 'username': employee.username, 'email': employee.email, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-fresh') + + def test_error_marks_sync_status(self): + employee = make_employee() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.side_effect = OSError('boom') + + result = sync_service.sync_employee(employee) + + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_sync_status, 'error') + + +class DeactivateActivateTests(TestCase): + def setUp(self): + self.service = mock.MagicMock() + patcher = mock.patch.object( + sync_service, 'MattermostService', return_value=self.service, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_deactivate_requires_linked_account(self): + employee = make_employee() + self.assertFalse(sync_service.deactivate_mattermost_user(employee)) + self.service.deactivate_user.assert_not_called() + + def test_deactivate_linked_user(self): + employee = make_employee(mattermost_user_id='mm-abc') + result = sync_service.deactivate_mattermost_user(employee) + self.assertTrue(result) + self.service.deactivate_user.assert_called_once_with('mm-abc') + + +class MattermostSignalTests(TestCase): + def test_create_employee_with_sync_queues_task(self): + with mock.patch( + 'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay', + ) as delay: + employee = make_employee(mattermost_sync_enabled=True) + delay.assert_called_once_with(employee.pk) + + def test_create_employee_without_sync_does_not_queue(self): + with mock.patch( + 'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay', + ) as delay: + make_employee(mattermost_sync_enabled=False) + delay.assert_not_called() + + def test_block_employee_deactivates_account(self): + employee = make_employee(mattermost_user_id='mm-abc') + with mock.patch( + 'apps.mattermost_connector.services.sync_service.deactivate_mattermost_user', + ) as deactivate: + employee.is_active = False + employee.save() + deactivate.assert_called_once() diff --git a/core/tests.py b/core/tests.py index 7ce503c..e0965b5 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,206 @@ +""" +Тесты core: смоук всех страниц портала, заявки IT, справочники, +структура и перестройка дерева Directory. +""" +from datetime import date + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.db.models import Count from django.test import TestCase -# Create your tests here. +from apps.it_manager.models import Ticket +from core.models import Directory, DirectoryType + +Employee = get_user_model() + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'core.user{_employee_seq}', + 'email': f'core.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +class PortalPagesSmokeTests(TestCase): + """Все страницы портала отдают 200 — и как HTMX-фрагмент, и в полном шелле""" + + PAGES = [ + '/dashboard/', '/employees/', '/pharmacies/', '/structure/', + '/timetrack/', '/calendar/', '/reports/', '/documents/', + '/recruiting/', '/settings/', '/news/', '/tasks/', '/tasks/detail/', + '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', + '/help/', '/it-support/', '/ad-mapping/', '/pharmacies/detail/', + ] + + @classmethod + def setUpTestData(cls): + cls.user = make_employee(is_staff=True, is_superuser=True) + + def test_all_pages_render(self): + self.client.force_login(self.user) + for url in self.PAGES: + with self.subTest(url=url): + fragment = self.client.get(url, HTTP_HX_REQUEST='true') + self.assertEqual(fragment.status_code, 200, url) + full = self.client.get(url) + self.assertEqual(full.status_code, 200, url) + + def test_anonymous_is_redirected_to_login(self): + response = self.client.get('/dashboard/') + self.assertEqual(response.status_code, 302) + self.assertIn('login', response.url) + + def test_login_page_renders(self): + response = self.client.get('/login/') + self.assertEqual(response.status_code, 200) + + +class ITSupportViewTests(TestCase): + def setUp(self): + self.user = make_employee() + self.client.force_login(self.user) + + def test_post_creates_ticket_for_requester(self): + response = self.client.post('/it-support/', { + 'title': 'Не працює принтер', + 'description': 'Кабінет 204, принтер не друкує', + 'ticket_type': 'repair', + 'priority': 'high', + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + ticket = Ticket.objects.get(requester=self.user) + self.assertEqual(ticket.title, 'Не працює принтер') + self.assertEqual(ticket.ticket_type, 'repair') + self.assertEqual(ticket.priority, 'high') + self.assertContains(response, ticket.number) + + def test_invalid_choices_fall_back_to_defaults(self): + self.client.post('/it-support/', { + 'title': 'Питання', + 'description': 'Опис', + 'ticket_type': 'hacked_value', + 'priority': 'hacked_value', + }, HTTP_HX_REQUEST='true') + ticket = Ticket.objects.get(requester=self.user) + self.assertEqual(ticket.ticket_type, 'other') + self.assertEqual(ticket.priority, 'medium') + + def test_empty_title_does_not_create_ticket(self): + self.client.post('/it-support/', { + 'title': '', 'description': 'Опис', + }, HTTP_HX_REQUEST='true') + self.assertFalse(Ticket.objects.exists()) + + def test_my_tickets_shows_only_own(self): + other = make_employee() + Ticket.objects.create( + title='Чужа заявка', description='x', + ticket_type='other', requester=other, + ) + response = self.client.get('/it-support/', HTTP_HX_REQUEST='true') + self.assertNotContains(response, 'Чужа заявка') + + +class DatabasesViewTests(TestCase): + def setUp(self): + self.user = make_employee() + # в тестовой базе уже есть типы, созданные data-миграциями (equipment_brand) + self.base_types = DirectoryType.objects.count() + self.base_records = Directory.objects.count() + self.base_active = Directory.objects.filter(is_active=True).count() + self.dtype = DirectoryType.objects.create( + code='test_type', name='Тестовий довідник', hierarchy_type='strict', + ) + Directory.objects.create(directory_type=self.dtype, name='Запис 1') + Directory.objects.create(directory_type=self.dtype, name='Запис 2', is_active=False) + + def test_counts_and_listing(self): + self.client.force_login(self.user) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Тестовий довідник') + self.assertEqual(response.context['types_count'], self.base_types + 1) + self.assertEqual(response.context['records_count'], self.base_records + 2) + self.assertEqual(response.context['active_records_count'], self.base_active + 1) + + def test_admin_link_visible_only_to_staff(self): + self.client.force_login(self.user) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertNotContains(response, '/admin/core/directory/') + + staff = make_employee(is_staff=True) + self.client.force_login(staff) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertContains(response, '/admin/core/directory/') + + +class StructureViewTests(TestCase): + def setUp(self): + self.dtype = DirectoryType.objects.create( + code='department', name='Відділи', hierarchy_type='strict', + ) + self.dept = Directory.objects.create(directory_type=self.dtype, name='IT-відділ') + + def test_department_head_heuristic(self): + head = make_employee(first_name='Керівник', last_name='Відділу', department=self.dept) + make_employee(department=self.dept, manager=head) + make_employee(department=self.dept, manager=head) + + self.client.force_login(head) + response = self.client.get('/structure/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + departments = response.context['departments'] + dept = next(d for d in departments if d.pk == self.dept.pk) + self.assertEqual(dept.head, head) + self.assertEqual(dept.employees_count, 3) + + def test_empty_department_has_no_head(self): + user = make_employee() + self.client.force_login(user) + response = self.client.get('/structure/', HTTP_HX_REQUEST='true') + departments = response.context['departments'] + dept = next(d for d in departments if d.pk == self.dept.pk) + self.assertIsNone(dept.head) + self.assertContains(response, 'Порожній') + + +class RebuildDirectoryTreeTests(TestCase): + def setUp(self): + self.dtype = DirectoryType.objects.create( + code='test_tree', name='Дерево', hierarchy_type='strict', + ) + self.root = Directory.objects.create(directory_type=self.dtype, name='Корінь') + self.child = Directory.objects.create( + directory_type=self.dtype, name='Дитина', parent=self.root, + ) + + def test_command_repairs_broken_paths(self): + # ломаем дерево так, как это делают data-миграции и импорт + Directory.objects.update(_path='') + call_command('rebuild_directory_tree', verbosity=0) + + self.root.refresh_from_db() + self.child.refresh_from_db() + self.assertTrue(self.root._path) + self.assertTrue(self.child._path) + self.assertTrue(self.child._path.startswith(self.root._path)) + + duplicates = ( + Directory.objects.values('_path') + .annotate(cnt=Count('id')).filter(cnt__gt=1) + ) + self.assertFalse(duplicates.exists()) diff --git a/core/views.py b/core/views.py index bf3296f..9c0858f 100644 --- a/core/views.py +++ b/core/views.py @@ -20,9 +20,22 @@ def index_view(request): @login_required def dashboard_view(request): + from django.utils import timezone as dj_timezone + from core.models.directories import DirectoryType + from apps.it_manager.models import Ticket + + context = { + 'today': dj_timezone.localdate(), + 'employees_active': Employee.objects.filter(is_active=True).count(), + 'employees_total': Employee.objects.count(), + 'pharmacies_active': Pharmacy.objects.filter(is_active=True).count(), + 'open_tickets': Ticket.objects.filter(status__in=['new', 'in_progress', 'waiting']).count(), + 'directory_types_count': DirectoryType.objects.filter(is_active=True).count(), + 'directories_count': Directory.objects.count(), + } if is_htmx_request(request): - return render(request, 'pages/dashboard.html') - return render(request, 'index.html', {'page_template': 'pages/dashboard.html'}) + return render(request, 'pages/dashboard.html', context) + return render(request, 'index.html', {'page_template': 'pages/dashboard.html', **context}) @login_required def employees_view(request): @@ -165,9 +178,73 @@ def recruiting_view(request): @login_required def structure_view(request): + from django.db.models import Count, Q + + # --- Офіс: відділи --- + departments = list( + Directory.objects.filter(directory_type__code='department', is_active=True) + .order_by('_path') + ) + office_employees = list( + Employee.objects.filter(is_active=True, user_location_type='office') + .select_related('post') + ) + by_dept = {} + for emp in office_employees: + by_dept.setdefault(emp.department_id, []).append(emp) + + palette = ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ec4899', '#06b6d4', '#64748b'] + for i, dept in enumerate(departments): + emps = by_dept.get(dept.id, []) + dept.color = palette[i % len(palette)] + dept.indent_depth = (dept._path or '').count('.') + dept.employees_count = len(emps) + dept.posts_count = len({e.post_id for e in emps if e.post_id}) + # Керівник: у нього є підлеглі у відділі, а його керівник — поза відділом + ids = {e.pk for e in emps} + managers_in_dept = {e.manager_id for e in emps if e.manager_id in ids} + candidates = [e for e in emps if e.pk in managers_in_dept] + dept.head = ( + next((e for e in candidates if e.manager_id not in ids), None) + or (candidates[0] if candidates else None) + ) + + # --- Аптеки --- + pharmacies = list( + Pharmacy.objects.filter(is_active=True) + .select_related('organization', 'head_pharmacist', 'location_region') + .annotate(staff_count=Count( + 'pharmacy_employees', + filter=Q(pharmacy_employees__is_active=True), + )) + .order_by('organization__name', 'pharmacy_number') + ) + for pharmacy in pharmacies: + if pharmacy.staff_count == 0: + pharmacy.staff_status = 'danger' + elif not pharmacy.head_pharmacist_id: + pharmacy.staff_status = 'warning' + else: + pharmacy.staff_status = 'ok' + + pharmacy_employees_count = Employee.objects.filter( + is_active=True, user_location_type='pharmacy', + ).count() + + context = { + 'departments': departments, + 'office_count': len(office_employees), + 'office_no_dept': sum(1 for e in office_employees if not e.department_id), + 'pharmacies': pharmacies, + 'pharmacy_employees_count': pharmacy_employees_count, + 'pharmacies_no_head': sum(1 for p in pharmacies if not p.head_pharmacist_id), + 'pharmacies_empty': sum(1 for p in pharmacies if p.staff_count == 0), + 'organizations_count': len({p.organization_id for p in pharmacies if p.organization_id}), + 'total_employees': Employee.objects.filter(is_active=True).count(), + } if is_htmx_request(request): - return render(request, 'pages/structure.html') - return render(request, 'index.html', {'page_template': 'pages/structure.html'}) + return render(request, 'pages/structure.html', context) + return render(request, 'index.html', {'page_template': 'pages/structure.html', **context}) @login_required def settings_view(request): @@ -176,6 +253,91 @@ def settings_view(request): return render(request, 'index.html', {'page_template': 'pages/settings.html'}) +def _static_page_view(template_name): + """Фабрика простых страниц: HTMX-фрагмент или полный layout""" + @login_required + def view(request): + if is_htmx_request(request): + return render(request, f'pages/{template_name}') + return render(request, 'index.html', {'page_template': f'pages/{template_name}'}) + return view + + +news_view = _static_page_view('news.html') +tasks_view = _static_page_view('tasks.html') +task_detail_view = _static_page_view('task-detail.html') +email_view = _static_page_view('email.html') +chat_view = _static_page_view('chat.html') +purchases_view = _static_page_view('purchases.html') +hr_portal_view = _static_page_view('hr-portal.html') +help_view = _static_page_view('help.html') +pharmacy_detail_view = _static_page_view('pharmacy-detail.html') + + +@login_required +def databases_view(request): + """Довідники: типи справочников с количеством записей""" + from django.db.models import Count, Max + from core.models.directories import DirectoryType + + directory_types = list( + DirectoryType.objects + .annotate(records=Count('directory'), last_updated=Max('directory__updated_at')) + .order_by('name') + ) + directories = Directory.objects.all() + + context = { + 'directory_types': directory_types, + 'types_count': len(directory_types), + 'records_count': directories.count(), + 'active_records_count': directories.filter(is_active=True).count(), + 'last_updated': max( + (dt.last_updated for dt in directory_types if dt.last_updated), + default=None, + ), + } + if is_htmx_request(request): + return render(request, 'pages/databases.html', context) + return render(request, 'index.html', {'page_template': 'pages/databases.html', **context}) + + +@login_required +def it_support_view(request): + """Підтримка IT: створення заявки та мої заявки""" + from apps.it_manager.models import Ticket + + ticket_created = None + if request.method == 'POST': + title = request.POST.get('title', '').strip() + description = request.POST.get('description', '').strip() + ticket_type = request.POST.get('ticket_type', 'other') + priority = request.POST.get('priority', 'medium') + if title and description: + valid_types = {v for v, _ in Ticket.TICKET_TYPES} + valid_priorities = {v for v, _ in Ticket.PRIORITY_CHOICES} + ticket_created = Ticket.objects.create( + title=title, + description=description, + ticket_type=ticket_type if ticket_type in valid_types else 'other', + priority=priority if priority in valid_priorities else 'medium', + requester=request.user, + ) + + my_tickets = Ticket.objects.filter(requester=request.user).order_by('-created_at')[:8] + context = { + 'ticket_types': Ticket.TICKET_TYPES, + 'my_tickets': my_tickets, + 'my_open_count': Ticket.objects.filter( + requester=request.user, status__in=['new', 'in_progress', 'waiting'], + ).count(), + 'ticket_created': ticket_created, + } + if is_htmx_request(request): + return render(request, 'pages/it-support.html', context) + return render(request, 'index.html', {'page_template': 'pages/it-support.html', **context}) + + def login_view(request): if request.method == 'POST': username = request.POST.get('username') diff --git a/static/assets/css/input.css b/static/assets/css/input.css index 20f18ec..c82ab30 100644 --- a/static/assets/css/input.css +++ b/static/assets/css/input.css @@ -17,8 +17,8 @@ --color-popover-foreground: hsl(215 25% 15%); /* Primary */ - --color-primary: hsl(215 60% 22%); - --color-primary-foreground: hsl(210 40% 98%); + --color-primary: hsl(224.8, 81%, 60.8%); + --color-primary-foreground: hsl(0 0% 100%); /* Secondary */ --color-secondary: hsl(210 25% 93%); @@ -39,20 +39,20 @@ /* Border / Input / Ring */ --color-border: hsl(214 20% 90%); --color-input: hsl(214 20% 90%); - --color-ring: hsl(215 60% 22%); + --color-ring: hsl(0 72% 44%); /* Radius */ --radius: 0.625rem; /* Sidebar */ - --color-sidebar: hsl(215 60% 22%); - --color-sidebar-foreground: hsl(210 30% 85%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar: hsl(0 0% 100%); + --color-sidebar-foreground: hsl(215 15% 40%); + --color-sidebar-primary: hsl(224.8, 81%, 60.8%); --color-sidebar-primary-foreground: hsl(0 0% 100%); - --color-sidebar-accent: hsl(215 50% 28%); - --color-sidebar-accent-foreground: hsl(210 30% 95%); - --color-sidebar-border: hsl(215 45% 30%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-accent: hsl(214 20% 94%); + --color-sidebar-accent-foreground: hsl(215 25% 20%); + --color-sidebar-border: hsl(214 20% 90%); + --color-sidebar-ring: hsl(0 72% 44%); /* Status */ --color-success: hsl(152 55% 45%); @@ -69,14 +69,14 @@ /* ─── Dark mode ──────────────────────────────────── */ @layer base { - .dark { + [data-theme="dark"] { --color-background: hsl(215 25% 10%); --color-foreground: hsl(210 40% 98%); --color-card: hsl(215 25% 13%); --color-card-foreground: hsl(210 40% 98%); --color-popover: hsl(215 25% 13%); --color-popover-foreground: hsl(210 40% 98%); - --color-primary: hsl(174 55% 50%); + --color-primary: hsl(208.1, 55.4%, 27.3%); --color-primary-foreground: hsl(215 60% 15%); --color-secondary: hsl(215 30% 20%); --color-secondary-foreground: hsl(210 40% 98%); @@ -88,15 +88,15 @@ --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(215 25% 22%); --color-input: hsl(215 25% 22%); - --color-ring: hsl(174 55% 50%); + --color-ring: hsl(208.1, 55.4%, 27.3%); --color-sidebar: hsl(215 30% 8%); --color-sidebar-foreground: hsl(210 20% 80%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar-primary: hsl(208.1, 55.4%, 27.3%); --color-sidebar-primary-foreground: hsl(0 0% 100%); --color-sidebar-accent: hsl(215 25% 15%); --color-sidebar-accent-foreground: hsl(210 20% 90%); --color-sidebar-border: hsl(215 25% 18%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-ring: hsl(208.1, 55.4%, 27.3%); --color-success: hsl(152 55% 45%); --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); @@ -105,6 +105,30 @@ --color-info-foreground: hsl(0 0% 100%); } + /* Slate — темний сайдбар, світлий контент */ + [data-theme="slate"] { + --color-sidebar: hsl(215 30% 14%); + --color-sidebar-foreground: hsl(210 20% 70%); + --color-sidebar-primary: hsl(224.8 81% 65%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-accent: hsl(215 30% 22%); + --color-sidebar-accent-foreground: hsl(210 20% 92%); + --color-sidebar-border: hsl(215 25% 20%); + --color-sidebar-ring: hsl(224.8 81% 60%); + } + + /* Emerald — зелений акцент */ + [data-theme="emerald"] { + --color-primary: hsl(152 58% 38%); + --color-primary-foreground: hsl(0 0% 100%); + --color-accent: hsl(152 55% 42%); + --color-accent-foreground: hsl(0 0% 100%); + --color-ring: hsl(152 58% 38%); + --color-sidebar-primary: hsl(152 58% 38%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-ring: hsl(152 58% 38%); + } + * { border-color: var(--color-border); } @@ -146,6 +170,7 @@ flex-direction: column; transition: width 200ms ease-in-out; overflow: hidden; + border-right: 1px solid var(--color-sidebar-border); } .sidebar.collapsed { @@ -166,8 +191,8 @@ width: 2rem; height: 2rem; border-radius: 0.5rem; - background-color: var(--color-sidebar-primary); - color: var(--color-sidebar-primary-foreground); + /* background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); */ display: flex; align-items: center; justify-content: center; @@ -212,8 +237,8 @@ } .nav-item.active { - background-color: var(--color-sidebar-accent); - color: var(--color-sidebar-accent-foreground); + background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); font-weight: 500; } @@ -356,7 +381,7 @@ &::placeholder { color: var(--color-muted-foreground); } &:focus { border-color: var(--color-ring); - box-shadow: 0 0 0 3px hsl(215 60% 22% / 0.1); + box-shadow: 0 0 0 3px hsl(0 72% 44% / 0.1); } } @@ -364,6 +389,21 @@ .tr-hover:hover { background-color: var(--color-muted); } .li-hover:hover { background-color: var(--color-muted); } + /* ─── Task row ─── */ + .task-row { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1.25rem; + } + + .task-meta { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + } + /* ─── Прогресс-бар ─── */ .progress { height: 4px; @@ -378,4 +418,197 @@ background-color: var(--color-accent); transition: width 300ms ease; } + + /* ─── Backdrop для мобильного сайдбара ─── */ + .sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 199; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } + + .sidebar-backdrop.active { + display: block; + } + + /* ─── Bottom navigation (скрыт на десктопе) ─── */ + .bottom-nav { + display: none; + } + + /* ─── Theme switcher ─── */ + .theme-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + border-radius: calc(var(--radius) - 2px); + font-size: 0.8125rem; + color: var(--color-foreground); + background: transparent; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 150ms; + } + + .theme-option:hover { background: var(--color-muted); } + + .theme-option.active { + background: var(--color-muted); + font-weight: 500; + } + + .theme-check { + margin-left: auto; + font-size: 0.75rem; + color: var(--color-primary); + opacity: 0; + } + + .theme-option.active .theme-check { opacity: 1; } + + .theme-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid rgba(0,0,0,0.12); + } +} + +/* ─── Mobile breakpoint ─────────────────────── */ +@media (max-width: 767px) { + + /* Сайдбар как overlay */ + .sidebar { + position: fixed !important; + left: 0; + top: 0; + z-index: 200; + width: 220px !important; + height: 100dvh !important; + transform: translateX(-100%); + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18); + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + /* Отменяем collapsed-состояние на мобиле */ + .sidebar.collapsed { + width: 220px !important; + transform: translateX(-100%); + } + .sidebar.collapsed.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed .sidebar-logo-text { opacity: 1 !important; width: auto !important; pointer-events: auto !important; } + .sidebar.collapsed .nav-section-label { opacity: 0.4 !important; height: auto !important; padding: 0.5rem 0.625rem 0.25rem !important; overflow: visible !important; } + .sidebar.collapsed .nav-label-text { display: inline !important; } + .sidebar.collapsed .nav-badge { display: inline-flex !important; } + .sidebar.collapsed .nav-item { justify-content: flex-start !important; padding: 0.4375rem 0.625rem !important; gap: 0.5rem !important; } + .sidebar.collapsed .sidebar-header { justify-content: flex-start !important; padding: 0 1rem !important; gap: 0.75rem !important; } + .sidebar.collapsed .sidebar-footer { padding: 0.75rem !important; } + .sidebar.collapsed .sidebar-footer .sidebar-logo-text { opacity: 1 !important; width: auto !important; pointer-events: auto !important; } + .sidebar.collapsed .sidebar-footer .footer-actions { display: flex !important; } + + /* Основной контент — не смещается, но имеет отступ снизу под bottom nav */ + #main-content { + padding-bottom: calc(1.5rem + 60px + env(safe-area-inset-bottom)) !important; + } + + /* Task rows: meta wraps to second line */ + .task-row { + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + + .task-meta { + width: 100%; + margin-left: 1.625rem; + flex-wrap: wrap; + gap: 0.375rem; + } + + .task-date { display: none !important; } + + /* Tabs: горизонтальный скролл вместо переноса */ + .task-tabs { + width: 100% !important; + overflow-x: auto; + flex-wrap: nowrap !important; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .task-tabs::-webkit-scrollbar { display: none; } + + /* Скрыть поиск в хедере на мобиле */ + .header-search { + display: none !important; + } + + /* ─── Bottom navigation ─── */ + .bottom-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--color-card); + border-top: 1px solid var(--color-border); + z-index: 100; + align-items: stretch; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.07); + } + + .bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--color-muted-foreground); + font-size: 0.5625rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: color 150ms; + border: none; + background: transparent; + padding: 0; + -webkit-tap-highlight-color: transparent; + min-width: 44px; + } + + .bottom-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; + opacity: 0.7; + transition: opacity 150ms; + } + + .bottom-nav-item.active { + color: var(--color-primary); + } + + .bottom-nav-item.active svg { + opacity: 1; + } + + .bottom-nav-item:active { + opacity: 0.7; + } } diff --git a/static/assets/css/main.css b/static/assets/css/main.css index 680612f..c97803b 100644 --- a/static/assets/css/main.css +++ b/static/assets/css/main.css @@ -17,10 +17,8 @@ --color-foreground: hsl(215 25% 15%); --color-card: hsl(0 0% 100%); --color-card-foreground: hsl(215 25% 15%); - --color-popover: hsl(0 0% 100%); - --color-popover-foreground: hsl(215 25% 15%); - --color-primary: hsl(215 60% 22%); - --color-primary-foreground: hsl(210 40% 98%); + --color-primary: hsl(224.8, 81%, 60.8%); + --color-primary-foreground: hsl(0 0% 100%); --color-secondary: hsl(210 25% 93%); --color-secondary-foreground: hsl(215 25% 15%); --color-muted: hsl(210 20% 96%); @@ -28,24 +26,18 @@ --color-accent: hsl(174 55% 42%); --color-accent-foreground: hsl(0 0% 100%); --color-destructive: hsl(0 72% 51%); - --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(214 20% 90%); --color-input: hsl(214 20% 90%); - --color-ring: hsl(215 60% 22%); - --color-sidebar: hsl(215 60% 22%); - --color-sidebar-foreground: hsl(210 30% 85%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-ring: hsl(0 72% 44%); + --color-sidebar: hsl(0 0% 100%); + --color-sidebar-foreground: hsl(215 15% 40%); + --color-sidebar-primary: hsl(224.8, 81%, 60.8%); --color-sidebar-primary-foreground: hsl(0 0% 100%); - --color-sidebar-accent: hsl(215 50% 28%); - --color-sidebar-accent-foreground: hsl(210 30% 95%); - --color-sidebar-border: hsl(215 45% 30%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-accent: hsl(214 20% 94%); + --color-sidebar-accent-foreground: hsl(215 25% 20%); + --color-sidebar-border: hsl(214 20% 90%); --color-success: hsl(152 55% 45%); - --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); - --color-warning-foreground: hsl(0 0% 100%); - --color-info: hsl(205 80% 55%); - --color-info-foreground: hsl(0 0% 100%); } } @layer base { @@ -197,21 +189,24 @@ } } @layer utilities { - .mb-6 { - margin-bottom: calc(var(--spacing) * 6); + .start { + inset-inline-start: var(--spacing); } .block { display: block; } + .flex { + display: flex; + } + .hidden { + display: none; + } .table { display: table; } .flex-shrink { flex-shrink: 1; } - .border-collapse { - border-collapse: collapse; - } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -222,13 +217,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .underline { - text-decoration-line: underline; - } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -239,14 +227,14 @@ } } @layer base { - .dark { + [data-theme="dark"] { --color-background: hsl(215 25% 10%); --color-foreground: hsl(210 40% 98%); --color-card: hsl(215 25% 13%); --color-card-foreground: hsl(210 40% 98%); --color-popover: hsl(215 25% 13%); --color-popover-foreground: hsl(210 40% 98%); - --color-primary: hsl(174 55% 50%); + --color-primary: hsl(208.1, 55.4%, 27.3%); --color-primary-foreground: hsl(215 60% 15%); --color-secondary: hsl(215 30% 20%); --color-secondary-foreground: hsl(210 40% 98%); @@ -258,15 +246,15 @@ --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(215 25% 22%); --color-input: hsl(215 25% 22%); - --color-ring: hsl(174 55% 50%); + --color-ring: hsl(208.1, 55.4%, 27.3%); --color-sidebar: hsl(215 30% 8%); --color-sidebar-foreground: hsl(210 20% 80%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar-primary: hsl(208.1, 55.4%, 27.3%); --color-sidebar-primary-foreground: hsl(0 0% 100%); --color-sidebar-accent: hsl(215 25% 15%); --color-sidebar-accent-foreground: hsl(210 20% 90%); --color-sidebar-border: hsl(215 25% 18%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-ring: hsl(208.1, 55.4%, 27.3%); --color-success: hsl(152 55% 45%); --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); @@ -274,6 +262,26 @@ --color-info: hsl(205 80% 55%); --color-info-foreground: hsl(0 0% 100%); } + [data-theme="slate"] { + --color-sidebar: hsl(215 30% 14%); + --color-sidebar-foreground: hsl(210 20% 70%); + --color-sidebar-primary: hsl(224.8 81% 65%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-accent: hsl(215 30% 22%); + --color-sidebar-accent-foreground: hsl(210 20% 92%); + --color-sidebar-border: hsl(215 25% 20%); + --color-sidebar-ring: hsl(224.8 81% 60%); + } + [data-theme="emerald"] { + --color-primary: hsl(152 58% 38%); + --color-primary-foreground: hsl(0 0% 100%); + --color-accent: hsl(152 55% 42%); + --color-accent-foreground: hsl(0 0% 100%); + --color-ring: hsl(152 58% 38%); + --color-sidebar-primary: hsl(152 58% 38%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-ring: hsl(152 58% 38%); + } * { border-color: var(--color-border); } @@ -307,6 +315,7 @@ flex-direction: column; transition: width 200ms ease-in-out; overflow: hidden; + border-right: 1px solid var(--color-sidebar-border); } .sidebar.collapsed { width: 56px; @@ -324,8 +333,6 @@ width: 2rem; height: 2rem; border-radius: 0.5rem; - background-color: var(--color-sidebar-primary); - color: var(--color-sidebar-primary-foreground); display: flex; align-items: center; justify-content: center; @@ -395,8 +402,8 @@ color: var(--color-sidebar-accent-foreground); } .nav-item.active { - background-color: var(--color-sidebar-accent); - color: var(--color-sidebar-accent-foreground); + background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); font-weight: 500; } .nav-item svg { @@ -550,7 +557,7 @@ } &:focus { border-color: var(--color-ring); - box-shadow: 0 0 0 3px hsl(215 60% 22% / 0.1); + box-shadow: 0 0 0 3px hsl(0 72% 44% / 0.1); } } .tr-hover:hover { @@ -559,6 +566,18 @@ .li-hover:hover { background-color: var(--color-muted); } + .task-row { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1.25rem; + } + .task-meta { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + } .progress { height: 4px; background-color: var(--color-muted); @@ -571,6 +590,200 @@ background-color: var(--color-accent); transition: width 300ms ease; } + .sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 199; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } + .sidebar-backdrop.active { + display: block; + } + .bottom-nav { + display: none; + } + .theme-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + border-radius: calc(var(--radius) - 2px); + font-size: 0.8125rem; + color: var(--color-foreground); + background: transparent; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 150ms; + } + .theme-option:hover { + background: var(--color-muted); + } + .theme-option.active { + background: var(--color-muted); + font-weight: 500; + } + .theme-check { + margin-left: auto; + font-size: 0.75rem; + color: var(--color-primary); + opacity: 0; + } + .theme-option.active .theme-check { + opacity: 1; + } + .theme-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid rgba(0,0,0,0.12); + } +} +@media (max-width: 767px) { + .sidebar { + position: fixed !important; + left: 0; + top: 0; + z-index: 200; + width: 220px !important; + height: 100dvh !important; + transform: translateX(-100%); + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18); + } + .sidebar.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed { + width: 220px !important; + transform: translateX(-100%); + } + .sidebar.collapsed.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed .sidebar-logo-text { + opacity: 1 !important; + width: auto !important; + pointer-events: auto !important; + } + .sidebar.collapsed .nav-section-label { + opacity: 0.4 !important; + height: auto !important; + padding: 0.5rem 0.625rem 0.25rem !important; + overflow: visible !important; + } + .sidebar.collapsed .nav-label-text { + display: inline !important; + } + .sidebar.collapsed .nav-badge { + display: inline-flex !important; + } + .sidebar.collapsed .nav-item { + justify-content: flex-start !important; + padding: 0.4375rem 0.625rem !important; + gap: 0.5rem !important; + } + .sidebar.collapsed .sidebar-header { + justify-content: flex-start !important; + padding: 0 1rem !important; + gap: 0.75rem !important; + } + .sidebar.collapsed .sidebar-footer { + padding: 0.75rem !important; + } + .sidebar.collapsed .sidebar-footer .sidebar-logo-text { + opacity: 1 !important; + width: auto !important; + pointer-events: auto !important; + } + .sidebar.collapsed .sidebar-footer .footer-actions { + display: flex !important; + } + #main-content { + padding-bottom: calc(1.5rem + 60px + env(safe-area-inset-bottom)) !important; + } + .task-row { + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + .task-meta { + width: 100%; + margin-left: 1.625rem; + flex-wrap: wrap; + gap: 0.375rem; + } + .task-date { + display: none !important; + } + .task-tabs { + width: 100% !important; + overflow-x: auto; + flex-wrap: nowrap !important; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .task-tabs::-webkit-scrollbar { + display: none; + } + .header-search { + display: none !important; + } + .bottom-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--color-card); + border-top: 1px solid var(--color-border); + z-index: 100; + align-items: stretch; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.07); + } + .bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--color-muted-foreground); + font-size: 0.5625rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: color 150ms; + border: none; + background: transparent; + padding: 0; + -webkit-tap-highlight-color: transparent; + min-width: 44px; + } + .bottom-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; + opacity: 0.7; + transition: opacity 150ms; + } + .bottom-nav-item.active { + color: var(--color-primary); + } + .bottom-nav-item.active svg { + opacity: 1; + } + .bottom-nav-item:active { + opacity: 0.7; + } } @property --tw-rotate-x { syntax: "*"; @@ -597,11 +810,6 @@ inherits: false; initial-value: solid; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -664,7 +872,6 @@ --tw-skew-x: initial; --tw-skew-y: initial; --tw-border-style: solid; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/static/assets/img/logosm.svg b/static/assets/img/logosm.svg new file mode 100755 index 0000000..2bc6fda --- /dev/null +++ b/static/assets/img/logosm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 5193a87..0cb8807 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,11 +3,12 @@
- -