commit 6fb2cdcea0e96b61eec87953e6e9d6aebefce92c Author: Игорь Надеин Date: Wed Jul 30 08:15:53 2025 +0300 feat: enhance Employee model and integrate Telegram/WhatsApp bot - Add birth_date, created_at/updated_at to Employee; remove username field - Introduce EmployeeAction for tracking HR events and is_actually_active/status properties - Create BotSubscriber model (OneToOne relation) for TG/WA binding - Implement Aiogram-based Telegram bot: • request contact to bind by phone or @username • department selection keyboard from DEPARTMENTS dict • async ORM via sync_to_async, custom filters for bound & active employees - Update profile view & template: • show “Bind Telegram/WhatsApp” button only when not already bound • preview avatar on upload - Remove registration tokens in favor of contact-based auth - Clean up templates, forms and add avatar validation mixin diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fd47cfcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,155 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Папки, создаваемые средой разработки +.idea +.DS_Store +.AppleDouble +.LSOverride + +*.sublime-project +*.sublime-workspace + +.vscode/ +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +.mypy_cache + +# папки со статикой и медиа +media/ + +# Others +node_modules + +# Docker +docker-compose.yml diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..9be1b6d9 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +# .dockerignore +venv +.git +db.sqlite3 +.env +.idea +.vscode \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..6279f9c2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13.5 +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt --no-cache-dir +RUN apt-get update && apt-get install -y postgresql-client +COPY . . +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "foodgram_backend.wsgi"] diff --git a/backend/TEST.py b/backend/TEST.py new file mode 100644 index 00000000..92919b67 --- /dev/null +++ b/backend/TEST.py @@ -0,0 +1,28 @@ +import requests + +# Задай свои значения: +app_key = 'admin' +app_secret = 'Baguvix26' +base_url = 'http://127.0.0.1:8208' + + +# В Hikvision OpenAPI по умолчанию +url = f'{base_url}/artemis/api/artemis/v1/agreementService/token/get' + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', +} + +data = { + 'appKey': app_key, + 'appSecret': app_secret, +} + +# Для некоторых систем нужен verify=False (если self-signed сертификат) +response = requests.post(url, json=data, headers=headers, verify=False) + +if response.status_code == 200: + print('Ответ:', response.json()) +else: + print('Ошибка:', response.status_code, response.text) diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/admin.py b/backend/api/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/api/apps.py b/backend/api/apps.py new file mode 100644 index 00000000..66656fd2 --- /dev/null +++ b/backend/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/backend/api/migrations/__init__.py b/backend/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/models.py b/backend/api/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/api/tests.py b/backend/api/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/api/views.py b/backend/api/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/api/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/bots/__init__.py b/backend/bots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bots/admin.py b/backend/bots/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/bots/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/bots/apps.py b/backend/bots/apps.py new file mode 100644 index 00000000..733cbca5 --- /dev/null +++ b/backend/bots/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BotsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'bots' diff --git a/backend/bots/filters.py b/backend/bots/filters.py new file mode 100644 index 00000000..b22ef213 --- /dev/null +++ b/backend/bots/filters.py @@ -0,0 +1,40 @@ +# bots/filters.py +from aiogram import types, F +from aiogram.filters import BaseFilter +from asgiref.sync import sync_to_async +from bots.models import BotSubscriber + + +class IsBoundSubscriber_tg(BaseFilter): + """ + Пропускает только тех, чей telegram_id уже привязан. + """ + + async def __call__(self, event: types.Message | types.CallbackQuery) -> bool: + user_id = event.from_user.id + return await sync_to_async( + BotSubscriber.objects.filter(telegram_id=user_id).exists + )() + + +class IsEmployeeActive(BaseFilter): + """ + Пропускает только работающих сотрудников + (последний кадровый статус — активный). + """ + + async def __call__(self, event: types.Message | types.CallbackQuery) -> bool: + user_id = event.from_user.id + + # Получаем подписчика вместе с юзером + subscriber = await sync_to_async( + lambda: BotSubscriber.objects.select_related('user') + .filter(telegram_id=user_id) + .first() + )() + + if not subscriber or not subscriber.user: + return False + + # Проверяем свойство is_actually_active у Employee + return await sync_to_async(lambda u: u.is_actually_active)(subscriber.user) diff --git a/backend/bots/management/__init__.py b/backend/bots/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bots/management/commands/__init__.py b/backend/bots/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bots/management/commands/start_telegram_bot.py b/backend/bots/management/commands/start_telegram_bot.py new file mode 100644 index 00000000..1fdd9a38 --- /dev/null +++ b/backend/bots/management/commands/start_telegram_bot.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand +from bots.services.telegram import run_telegram_bot + + +class Command(BaseCommand): + help = "Запуск Telegram-бота для рассылки и привязки сотрудников" + + def handle(self, *args, **options): + run_telegram_bot() diff --git a/backend/bots/migrations/0001_initial.py b/backend/bots/migrations/0001_initial.py new file mode 100644 index 00000000..4e16fdc9 --- /dev/null +++ b/backend/bots/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.4 on 2025-07-25 11:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BotSubscriber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('telegram_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='Telegram ID')), + ('whatsapp_id', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='WhatsApp ID')), + ('is_active', models.BooleanField(default=True)), + ('subscribed_at', models.DateTimeField(auto_now_add=True)), + ('registration_token', models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='Токен для привязки')), + ('token_created', models.DateTimeField(blank=True, null=True, verbose_name='Дата генерации токена')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Подписчик бота', + 'verbose_name_plural': 'Подписчики ботов', + }, + ), + ] diff --git a/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py b/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py new file mode 100644 index 00000000..d449b398 --- /dev/null +++ b/backend/bots/migrations/0002_remove_botsubscriber_is_active_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.4 on 2025-07-28 06:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='botsubscriber', + name='is_active', + ), + migrations.RemoveField( + model_name='botsubscriber', + name='registration_token', + ), + migrations.RemoveField( + model_name='botsubscriber', + name='token_created', + ), + migrations.AddField( + model_name='botsubscriber', + name='unsubscribed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Дата отвязки'), + ), + migrations.AlterField( + model_name='botsubscriber', + name='subscribed_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Дата привязки'), + ), + migrations.AlterField( + model_name='botsubscriber', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bot_subscription', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py b/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py new file mode 100644 index 00000000..2b991ffc --- /dev/null +++ b/backend/bots/migrations/0003_remove_botsubscriber_subscribed_at_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.4 on 2025-07-28 08:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0002_remove_botsubscriber_is_active_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='botsubscriber', + name='subscribed_at', + ), + migrations.RemoveField( + model_name='botsubscriber', + name='unsubscribed_at', + ), + ] diff --git a/backend/bots/migrations/__init__.py b/backend/bots/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bots/models.py b/backend/bots/models.py new file mode 100644 index 00000000..ac56017d --- /dev/null +++ b/backend/bots/models.py @@ -0,0 +1,32 @@ +# bots/models.py +import uuid +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class BotSubscriber(models.Model): + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='bot_subscription' + ) + telegram_id = models.BigIntegerField( + 'Telegram ID', + null=True, blank=True, + unique=True + ) + whatsapp_id = models.CharField( + 'WhatsApp ID', + max_length=64, + null=True, blank=True, + unique=True + ) + + class Meta: + verbose_name = 'Подписчик бота' + verbose_name_plural = 'Подписчики ботов' + + def __str__(self): + return f'{self.user} (TG={self.telegram_id or "-"} / WA={self.whatsapp_id or "-"})' diff --git a/backend/bots/services/telegram.py b/backend/bots/services/telegram.py new file mode 100644 index 00000000..18898c9f --- /dev/null +++ b/backend/bots/services/telegram.py @@ -0,0 +1,441 @@ +import os +import logging +from datetime import timedelta + +from dotenv import load_dotenv + +import django +from django.conf import settings +from django.db import models +from django.utils import timezone + +from aiogram import Bot, Dispatcher, types, F +from aiogram.filters import CommandStart +from aiogram.types import ( + InlineKeyboardMarkup, InlineKeyboardButton, + ReplyKeyboardMarkup, KeyboardButton, + FSInputFile, +) +from aiogram.exceptions import TelegramBadRequest +from asgiref.sync import sync_to_async +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from django.contrib.auth import get_user_model + +from calendar_app.models import CompanyEvent +from bots.models import BotSubscriber +from bots.filters import IsBoundSubscriber_tg, IsEmployeeActive +from documents.models import Document, DocumentAcknowledgement + +# Если запускается вне manage.py, настраиваем Django +if not settings.configured: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') + django.setup() + +# Логирование +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Загрузка .env +load_dotenv() +BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') + +# Инициализация бота и диспетчера +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher() + +# Модель сотрудника +Employee = get_user_model() + +# Отделы для inline‑меню +DEPARTMENTS = { + "department_manager": { + "title": "Менеджеры", + "groups": [ + {"name": "Группа 1", "link": "https://t.me/manager_group"}, + {"name": "Рабочий чат", "link": "https://t.me/manager_workchat"}, + ], + }, + "department_tech": { + "title": "Технари", + "groups": [ + {"name": "Группа 1", "link": "https://t.me/tech_group"}, + {"name": "Рабочий чат", "link": "https://t.me/tech_workchat"}, + ], + }, +} + +# — Клавиатуры — + + +def get_contact_kb() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[[ + KeyboardButton(text="📲 Поделиться контактом", request_contact=True) + ]], + resize_keyboard=True, + one_time_keyboard=True, + ) + + +def get_department_kb() -> InlineKeyboardMarkup: + buttons = [ + InlineKeyboardButton(text=dept["title"], callback_data=key) + for key, dept in DEPARTMENTS.items() + ] + return InlineKeyboardMarkup(inline_keyboard=[buttons]) + + +def main_menu_kb() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="📅 Календарь", + callback_data="menu_calendar"), + InlineKeyboardButton(text="📊 Статус", callback_data="menu_status"), + InlineKeyboardButton(text="📄 Документы", callback_data="menu_docs"), + ]]) + + +def department_menu_kb() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📅 На сегодня", + callback_data="cal_today")], + [InlineKeyboardButton(text="🗓 На неделю", + callback_data="cal_week")], + [InlineKeyboardButton(text="📆 На месяц", + callback_data="cal_month")], + [InlineKeyboardButton(text="◀️ Главное меню", + callback_data="go_main")], + ]) + + +async def safe_edit( + call: types.CallbackQuery, + text: str, + inline_kb: InlineKeyboardMarkup | None = None, + **kwargs +): + """ + Редактирует call.message: + - если передан inline_kb — edit_text, + - иначе — answer. + """ + msg = call.message + try: + if inline_kb: + await msg.edit_text(text, reply_markup=inline_kb, **kwargs) + else: + await msg.answer(text, **kwargs) + except TelegramBadRequest as e: + await call.answer(text=f"Загрузка...", cache_time=10) + # await msg.answer(text, reply_markup=inline_kb, **kwargs) + +# — ORM‑хелперы — + + +@sync_to_async +def find_employee_by_phone(phone: str): + return Employee.objects.filter(telegram__endswith=phone[-10:]).first() + + +@sync_to_async +def find_employee_by_username(username: str): + uname = username.lower().lstrip('@') + return Employee.objects.filter( + models.Q(telegram__iexact=uname) | + models.Q(telegram__iexact='@' + uname) + ).first() + + +@sync_to_async +def bind_telegram(user, tg_id: int): + BotSubscriber.objects.filter(telegram_id=tg_id).exclude(user=user)\ + .update(telegram_id=None) + return BotSubscriber.objects.update_or_create( + user=user, defaults={"telegram_id": tg_id} + ) + + +@sync_to_async +def _get_subscriber_user(tg_id: int): + sub = BotSubscriber.objects.select_related("user")\ + .filter(telegram_id=tg_id).first() + return sub.user if sub and sub.user else None + + +@sync_to_async +def _get_user_documents(user): + from django.db.models import Q + return list( + Document.objects.filter(Q(sent_to_all=True) | Q(recipients=user)) + .distinct().order_by('-uploaded_at') + ) + + +@sync_to_async +def _has_acknowledged(user, document): + return DocumentAcknowledgement.objects.filter(user=user, document=document).exists() + + +@sync_to_async +def _acknowledge(user, document): + return DocumentAcknowledgement.objects.get_or_create(user=user, document=document) + + +@sync_to_async +def _get_one_time_events(date): + return list(CompanyEvent.objects.filter( + recurrence=CompanyEvent.ONE_TIME, date=date + )) + + +@sync_to_async +def _get_annual_events(): + return list(CompanyEvent.objects.filter(recurrence=CompanyEvent.ANNUAL)) + + +@sync_to_async +def _get_subscribers(): + return list(BotSubscriber.objects.filter(telegram_id__isnull=False)) + +# — Ежедневная рассылка — + + +async def send_daily_notifications(): + today = timezone.localdate() + one_time = await _get_one_time_events(today) + annual_all = await _get_annual_events() + annual_today = [ + ev for ev in annual_all + if (ev.date.month, ev.date.day) == (today.month, today.day) + ] + events = one_time + annual_today + if not events: + logger.info("Нет событий на сегодня.") + return + + subs = await _get_subscribers() + for sub in subs: + for ev in events: + text = ( + f"📅 *{ev.title}*\n" + f"Дата: `{ev.date:%d.%m.%Y}`\n" + f"{ev.description or ''}" + ) + try: + await bot.send_message( + sub.telegram_id, + text, + parse_mode="Markdown", + ) + except: + logger.exception(f"Не удалось отправить {sub.telegram_id}") + +scheduler = AsyncIOScheduler(timezone=settings.TIME_ZONE) +scheduler.add_job(send_daily_notifications, "cron", hour=9, minute=0) + + +async def on_startup_dispatcher(): + logger.info("Запуск APScheduler…") + scheduler.start() + +dp.startup.register(on_startup_dispatcher) + +# — Хендлеры бота — + + +@dp.message(CommandStart()) +async def cmd_start(msg: types.Message): + await msg.answer( + "Здравствуйте! Чтобы подключиться, пожалуйста, поделитесь контактом:", + reply_markup=get_contact_kb() + ) + + +@dp.message(lambda m: m.contact is not None) +async def on_contact(msg: types.Message): + phone = msg.contact.phone_number + tg_id = msg.from_user.id + user = ( + await find_employee_by_phone(phone) + or (msg.from_user.username and await find_employee_by_username(msg.from_user.username)) + ) + if not user: + return await msg.answer("❌ Профиль не найден.") + await bind_telegram(user, tg_id) + await msg.answer( + f"✅ Спасибо, {user.first_name}! Выберите отдел:", + reply_markup=get_department_kb() + ) + + +@dp.callback_query(F.data.startswith("department_"), IsBoundSubscriber_tg(), IsEmployeeActive()) +async def department_selected(call: types.CallbackQuery): + dept = DEPARTMENTS.get(call.data) + text = ( + f"*{dept['title']}*:\n" + + "\n".join(f"• [{g['name']}]({g['link']})" for g in dept["groups"]) + ) if dept else "Неизвестный отдел." + await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown") + await call.answer() + + +@dp.callback_query(F.data == "menu_calendar", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def menu_calendar(call: types.CallbackQuery): + await safe_edit(call, "Выберите период для просмотра:", inline_kb=department_menu_kb()) + await call.answer() + + +@dp.callback_query(F.data == "menu_status", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def menu_status(call: types.CallbackQuery): + user = await _get_subscriber_user(call.from_user.id) + status = await sync_to_async(lambda u: u.employment_status)(user) if user else "неизвестен" + text = f"Ваш статус: *{status}*" + await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown") + # await call.answer(cache_time=60, text="Смотрите статус в сообщении") + + +@dp.callback_query(F.data == "cal_today", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def calendar_today(call: types.CallbackQuery): + today = timezone.localdate() + evs = (await _get_one_time_events(today)) + [ + ev for ev in await _get_annual_events() + if (ev.date.month, ev.date.day) == (today.month, today.day) + ] + text = ("Событий на сегодня нет." if not evs + else "📅 События сегодня:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs)) + kb = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main") + ]]) + await safe_edit(call, text, inline_kb=kb) + await call.answer() + + +@dp.callback_query(F.data == "cal_week", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def calendar_week(call: types.CallbackQuery): + start, end = timezone.localdate(), timezone.localdate() + timedelta(days=6) + one = await _get_one_time_events(start) + ann = await _get_annual_events() + evs = one + [ + ev for ev in ann + if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day) + ] + text = ("Событий на эту неделю нет." if not evs + else f"📅 С {start:%d.%m} по {end:%d.%m}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs)) + kb = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main") + ]]) + await safe_edit(call, text, inline_kb=kb) + await call.answer() + + +@dp.callback_query(F.data == "cal_month", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def calendar_month(call: types.CallbackQuery): + today = timezone.localdate() + start = today.replace(day=1) + nm = start.replace(month=(start.month % 12) + 1, + year=start.year + (start.month // 12)) + end = nm - timedelta(days=1) + one = await _get_one_time_events(start) + ann = await _get_annual_events() + evs = one + [ + ev for ev in ann + if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day) + ] + text = ("Событий в этом месяце нет." if not evs + else f"📅 {today:%B %Y}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs)) + kb = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main") + ]]) + await safe_edit(call, text, inline_kb=kb) + await call.answer() + + +@dp.callback_query(F.data == "go_main", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def go_main(call: types.CallbackQuery): + await safe_edit(call, "Главное меню:", inline_kb=main_menu_kb()) + await call.answer() + +# — Документы через бот — + + +@dp.callback_query(F.data == "menu_docs", IsBoundSubscriber_tg(), IsEmployeeActive()) +async def menu_docs(call: types.CallbackQuery): + user = await _get_subscriber_user(call.from_user.id) + docs = await _get_user_documents(user) if user else [] + if not docs: + text = "У вас нет доступных документов." + kb = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="◀️ Главное меню", + callback_data="go_main") + ]]) + else: + text = "📄 Ваши документы:\nВыберите для просмотра:" + rows = [ + [InlineKeyboardButton( + text=d.title[:30] + ("…" if len(d.title) > 30 else ""), + callback_data=f"doc_{d.id}" + )] + for d in docs[:5] + ] + rows.append([InlineKeyboardButton( + text="◀️ Главное меню", callback_data="go_main")]) + kb = InlineKeyboardMarkup(inline_keyboard=rows) + + await safe_edit(call, text, inline_kb=kb) + await call.answer() + + +@dp.callback_query(F.data.startswith("doc_"), IsBoundSubscriber_tg(), IsEmployeeActive()) +async def show_document(call: types.CallbackQuery): + user = await _get_subscriber_user(call.from_user.id) + doc_id = int(call.data.split("_", 1)[1]) + doc = await sync_to_async(Document.objects.get)(pk=doc_id) + + caption = ( + f"*{doc.title}*\n\n" + f"{doc.description or ''}\n\n" + f"Загружен: {doc.uploaded_at:%d.%m.%Y %H:%M}" + ) + already = await _has_acknowledged(user, doc) + + rows = [] + if not already: + rows.append([InlineKeyboardButton( + text="✅ Ознакомился", callback_data=f"ack_{doc.id}")]) + rows.append([InlineKeyboardButton( + text="◀️ Документы", callback_data="menu_docs")]) + kb = InlineKeyboardMarkup(inline_keyboard=rows) + + file_input = FSInputFile( + path=doc.file.path, filename=os.path.basename(doc.file.name)) + await bot.send_document( + chat_id=call.from_user.id, + document=file_input, + caption=caption, + parse_mode="Markdown", + reply_markup=kb + ) + await call.answer() + + +@dp.callback_query(F.data.startswith("ack_"), IsBoundSubscriber_tg(), IsEmployeeActive()) +async def ack_document(call: types.CallbackQuery): + user = await _get_subscriber_user(call.from_user.id) + doc_id = int(call.data.split("_", 1)[1]) + await _acknowledge(user, await sync_to_async(Document.objects.get)(pk=doc_id)) + + old = call.message.reply_markup.inline_keyboard + new = [row for row in old if not any( + btn.callback_data == f"ack_{doc_id}" for btn in row)] + kb = InlineKeyboardMarkup(inline_keyboard=new) + + await call.message.edit_reply_markup(reply_markup=kb) + await call.answer("Ознакомление зафиксировано.") + + +def run_telegram_bot(): + dp.run_polling(bot) + + +if __name__ == "__main__": + run_telegram_bot() diff --git a/backend/bots/tests.py b/backend/bots/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/bots/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/bots/utils.py b/backend/bots/utils.py new file mode 100644 index 00000000..620004ad --- /dev/null +++ b/backend/bots/utils.py @@ -0,0 +1,15 @@ +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +import secrets + +@login_required +def generate_messenger_token(request): + user = request.user + user.registration_token = secrets.token_hex(16) + user.save(update_fields=['registration_token']) + telegram_link = f"https://t.me/your_bot?start={user.registration_token}" + # Можно также вернуть QR-код + return render(request, 'users/messenger_token.html', { + 'telegram_link': telegram_link, + 'token': user.registration_token, + }) diff --git a/backend/bots/views.py b/backend/bots/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/bots/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/calendar_app/__init__.py b/backend/calendar_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/calendar_app/admin.py b/backend/calendar_app/admin.py new file mode 100644 index 00000000..03d81274 --- /dev/null +++ b/backend/calendar_app/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from .models import CompanyEvent + + +@admin.register(CompanyEvent) +class CompanyEventAdmin(admin.ModelAdmin): + list_display = ('title', 'date', 'recurrence', 'created_by') + list_filter = ('recurrence', 'date', 'created_by') + search_fields = ('title', 'description', 'created_by__username', 'created_by__phone_number') + date_hierarchy = 'date' + ordering = ('recurrence', 'date') + fieldsets = ( + (None, { + 'fields': ('title', 'description', 'date', 'recurrence') + }), + ('Meta', { + 'fields': ('created_by',), + 'classes': ('collapse',), + }), + ) + readonly_fields = ('created_by',) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.created_by = request.user + super().save_model(request, obj, form, change) diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py new file mode 100644 index 00000000..7dbe738a --- /dev/null +++ b/backend/calendar_app/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class CalendarAppConfig(AppConfig): + name = 'calendar_app' + + def ready(self): + import calendar_app.signals # noqa diff --git a/backend/calendar_app/migrations/0001_initial.py b/backend/calendar_app/migrations/0001_initial.py new file mode 100644 index 00000000..bf41c2fc --- /dev/null +++ b/backend/calendar_app/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.4 on 2025-07-28 10:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CompanyEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('date', models.DateField(verbose_name='Дата события')), + ('recurrence', models.CharField(choices=[('one_time', 'Одноразовое'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ], + options={ + 'verbose_name': 'Событие компании', + 'verbose_name_plural': 'События компании', + 'ordering': ['recurrence', 'date'], + }, + ), + ] diff --git a/backend/calendar_app/migrations/__init__.py b/backend/calendar_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py new file mode 100644 index 00000000..6ebcd940 --- /dev/null +++ b/backend/calendar_app/models.py @@ -0,0 +1,41 @@ +# calendar_app/models.py +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class CompanyEvent(models.Model): + ONE_TIME = 'one_time' + ANNUAL = 'annual' + RECURRENCE_CHOICES = [ + (ONE_TIME, _('Одноразовое')), + (ANNUAL, _('Ежегодно')), + ] + + title = models.CharField(_('Название'), max_length=200) + description = models.TextField(_('Описание'), blank=True) + date = models.DateField(_('Дата события')) + recurrence = models.CharField( + _('Повторение'), + max_length=20, + choices=RECURRENCE_CHOICES, + default=ONE_TIME + ) + created_by = models.ForeignKey( + User, verbose_name=_('Создал'), + on_delete=models.SET_NULL, null=True, blank=True + ) + + class Meta: + verbose_name = _('Событие компании') + verbose_name_plural = _('События компании') + ordering = ['recurrence', 'date'] + + def __str__(self): + return f'{self.title} — {self.date:%d.%m.%Y}' + + def get_absolute_url(self): + return reverse('calendar:event_detail', args=[self.pk]) diff --git a/backend/calendar_app/serializers.py b/backend/calendar_app/serializers.py new file mode 100644 index 00000000..e78a9842 --- /dev/null +++ b/backend/calendar_app/serializers.py @@ -0,0 +1,28 @@ +# calendar_app/serializers.py +from rest_framework import serializers +from django.utils.dateparse import parse_datetime, parse_date +from .models import CompanyEvent + + +class CompanyEventSerializer(serializers.ModelSerializer): + # переопределяем поле date + date = serializers.SerializerMethodField() + + class Meta: + model = CompanyEvent + fields = ['id', 'title', 'date', 'recurrence'] + + def get_date(self, obj): + request = self.context.get('request') + start_str = request.query_params.get('start') + # парсим start, чтобы узнать год + dt_start = parse_datetime(start_str) or parse_date(start_str) + if hasattr(dt_start, 'date'): + start = dt_start.date() + else: + start = dt_start + # если это annual — заменяем год на нужный + if obj.recurrence == CompanyEvent.ANNUAL and start: + return obj.date.replace(year=start.year).isoformat() + # иначе — отдаем оригинальную дату + return obj.date.isoformat() diff --git a/backend/calendar_app/signals.py b/backend/calendar_app/signals.py new file mode 100644 index 00000000..f8349f1d --- /dev/null +++ b/backend/calendar_app/signals.py @@ -0,0 +1,59 @@ +# calendar_app/signals.py + +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from .models import CompanyEvent +from users.models import Employee + +@receiver(pre_save, sender=Employee) +def cache_old_birth_date(sender, instance: Employee, **kwargs): + """ + Перед сохранением запомним старую дату рождения, + чтобы потом удалить соответствующее событие. + """ + if instance.pk: + try: + old = Employee.objects.get(pk=instance.pk) + instance._old_birth_date = old.birth_date + except Employee.DoesNotExist: + instance._old_birth_date = None + else: + instance._old_birth_date = None + +@receiver(post_save, sender=Employee) +def create_or_update_birthday(sender, instance: Employee, created, **kwargs): + """ + После сохранения: + - Удаляем старое событие, если birth_date поменялась. + - Если указана новая birth_date — создаём или обновляем событие. + """ + title = f'День рождения: {instance.last_name} {instance.first_name}' + + old_date = getattr(instance, '_old_birth_date', None) + new_date = instance.birth_date + + # 1. Удаляем старое событие, если дата изменилась и старое было задано + if old_date and old_date != new_date: + CompanyEvent.objects.filter( + title=title, + date=old_date, + recurrence=CompanyEvent.ANNUAL, + created_by=instance + ).delete() + + # 2. Создаём/обновляем новое ежегодное событие + if new_date: + CompanyEvent.objects.update_or_create( + title=title, + date=new_date, + recurrence=CompanyEvent.ANNUAL, + created_by=instance, + defaults={'description': f'Авто-создано для {instance.get_full_name()}'} + ) + else: + # Если дата убрана — тоже удаляем старое, если оно осталось + CompanyEvent.objects.filter( + title=title, + recurrence=CompanyEvent.ANNUAL, + created_by=instance + ).delete() diff --git a/backend/calendar_app/tests.py b/backend/calendar_app/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/calendar_app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/calendar_app/urls.py b/backend/calendar_app/urls.py new file mode 100644 index 00000000..90cb4ed2 --- /dev/null +++ b/backend/calendar_app/urls.py @@ -0,0 +1,9 @@ +# calendar_app/urls.py +from django.urls import path +from .views import CompanyEventListAPI + +app_name = 'calendar' + +urlpatterns = [ + path('api/events/', CompanyEventListAPI.as_view(), name='events_api'), +] diff --git a/backend/calendar_app/views.py b/backend/calendar_app/views.py new file mode 100644 index 00000000..aa550228 --- /dev/null +++ b/backend/calendar_app/views.py @@ -0,0 +1,56 @@ +# calendar_app/views.py +from rest_framework import generics +from django.utils.dateparse import parse_datetime, parse_date +from django.db.models import Q +from .models import CompanyEvent +from .serializers import CompanyEventSerializer + + +class CompanyEventListAPI(generics.ListAPIView): + serializer_class = CompanyEventSerializer + + def get_queryset(self): + qs = CompanyEvent.objects.all() + start_str = self.request.query_params.get('start') + end_str = self.request.query_params.get('end') + + if start_str and end_str: + # сначала пытаемся распарсить как datetime, иначе как date + dt_start = parse_datetime(start_str) or parse_date(start_str) + dt_end = parse_datetime(end_str) or parse_date(end_str) + # если получилось datetime, приводим к date + if hasattr(dt_start, 'date'): + start = dt_start.date() + else: + start = dt_start + if hasattr(dt_end, 'date'): + end = dt_end.date() + else: + end = dt_end + + # если всё ещё нет корректных дат — вернём пустой qs + if not start or not end: + return CompanyEvent.objects.none() + + # 1) одноразовые события внутри диапазона + one_time_q = Q(recurrence=CompanyEvent.ONE_TIME, + date__range=(start, end)) + + # 2) ежегодные события, чья (month, day) лежит в интервале [start, end] + annual_ids = [] + for ev in CompanyEvent.objects.filter(recurrence=CompanyEvent.ANNUAL): + md = (ev.date.month, ev.date.day) + # простой кейс: диапазон в рамках одного года + if start.year == end.year: + if (start.month, start.day) <= md <= (end.month, end.day): + annual_ids.append(ev.pk) + else: + # диапазон пересёк границу года + if md >= (start.month, start.day) or md <= (end.month, end.day): + annual_ids.append(ev.pk) + annual_q = Q(pk__in=annual_ids) + + return qs.filter(one_time_q | annual_q) + + # если start/end не заданы — вернём всё + return qs diff --git a/backend/documents/__init__.py b/backend/documents/__init__.py new file mode 100644 index 00000000..97c5c8aa --- /dev/null +++ b/backend/documents/__init__.py @@ -0,0 +1 @@ +default_app_config = 'documents.apps.DocumentsConfig' diff --git a/backend/documents/admin.py b/backend/documents/admin.py new file mode 100644 index 00000000..49db12c8 --- /dev/null +++ b/backend/documents/admin.py @@ -0,0 +1,116 @@ +# documents/admin.py + +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html, mark_safe +from django.urls import reverse +from .models import Document, DocumentAcknowledgement +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class AcknowledgementInline(admin.TabularInline): + model = DocumentAcknowledgement + extra = 0 + can_delete = False + verbose_name = _("Ознакомление") + verbose_name_plural = _("Ознакомления") + fields = ('user_link', 'acknowledged_at') + readonly_fields = ('user_link', 'acknowledged_at') + + def user_link(self, obj): + url = reverse( + 'admin:{}_{}_change'.format( + User._meta.app_label, User._meta.model_name + ), args=[obj.user.pk] + ) + return format_html('{}', url, obj.user.get_full_name() or obj.user) + user_link.short_description = _("Сотрудник") + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + inlines = [AcknowledgementInline] + + list_display = ( + 'title', 'uploaded_at', 'sent_to_all', + # убрали 'pending_summary' + ) + list_filter = ('sent_to_all', 'uploaded_at') + search_fields = ('title', 'description') + filter_horizontal = ('recipients',) + actions = ['send_document'] + + fieldsets = ( + (None, { + 'fields': ('title', 'file', 'description', 'recipients', 'sent_to_all'), + }), + (_('Meta'), { + 'fields': ('uploaded_by', 'uploaded_at'), + 'classes': ('collapse',), + }), + (_('Ознакомление'), { + 'fields': ('recipients_summary', 'pending_list'), + }), + ) + readonly_fields = ( + 'uploaded_by', 'uploaded_at', + 'recipients_summary', 'pending_list', + ) + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.uploaded_by = request.user + super().save_model(request, obj, form, change) + + def send_document(self, request, queryset): + from .tasks import send_document_to_recipients + for doc in queryset: + send_document_to_recipients.delay(doc.id) + self.message_user(request, _("Рассылка запущена.")) + send_document.short_description = _('Отправить документ') + + def get_recipients_qs(self, obj): + return User.objects.filter(is_active=True) if obj.sent_to_all else obj.recipients.all() + + def recipients_summary(self, obj): + qs = self.get_recipients_qs(obj) + names = [u.get_full_name() or str(u) for u in qs[:5]] + count = qs.count() + if count > 5: + return format_html("{}… (ещё {})", ", ".join(names), count - 5) + return ", ".join(names) if names else _("— никто —") + recipients_summary.short_description = _('Получатели') + + def pending_list(self, obj): + recipients = set(self.get_recipients_qs(obj)) + acked_ids = set( + DocumentAcknowledgement.objects + .filter(document=obj) + .values_list('user_id', flat=True) + ) + pending = [u for u in recipients if u.id not in acked_ids] + if not pending: + return format_html('{}', _("Все ознакомились")) + links = [] + for u in pending: + url = reverse( + 'admin:{}_{}_change'.format( + User._meta.app_label, User._meta.model_name + ), args=[u.pk] + ) + links.append(format_html('{}', + url, u.get_full_name() or u)) + return mark_safe('
'.join(links)) + pending_list.short_description = _('Кто ещё не ознакомился') + + +@admin.register(DocumentAcknowledgement) +class DocumentAcknowledgementAdmin(admin.ModelAdmin): + list_display = ('document', 'user', 'acknowledged_at') + list_filter = ('document', 'acknowledged_at') + search_fields = ('user__phone_number', + 'user__last_name', 'document__title') + readonly_fields = ('user', 'document', 'acknowledged_at') + ordering = ('-acknowledged_at',) diff --git a/backend/documents/apps.py b/backend/documents/apps.py new file mode 100644 index 00000000..d5698983 --- /dev/null +++ b/backend/documents/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class DocumentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'documents' + + def ready(self): + # импортируем сигналы, чтобы они подхватились + import documents.signals # noqa diff --git a/backend/documents/migrations/0001_initial.py b/backend/documents/migrations/0001_initial.py new file mode 100644 index 00000000..a6c5c706 --- /dev/null +++ b/backend/documents/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.4 on 2025-07-29 05:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Название')), + ('file', models.FileField(upload_to='documents/%Y/%m/%d/', verbose_name='Файл')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки')), + ('sent_to_all', models.BooleanField(default=False, help_text='Если включено — уведомление получат все активные сотрудники', verbose_name='Разослать всем')), + ('recipients', models.ManyToManyField(blank=True, help_text='Если `Разослать всем` отключено, документ пойдет только этим', related_name='document_recipients', to=settings.AUTH_USER_MODEL, verbose_name='Получатели')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')), + ], + options={ + 'verbose_name': 'Документ', + 'verbose_name_plural': 'Документы', + 'ordering': ['-uploaded_at'], + }, + ), + migrations.CreateModel( + name='DocumentAcknowledgement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('acknowledged_at', models.DateTimeField(auto_now_add=True, verbose_name='Время ознакомления')), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgements', to='documents.document', verbose_name='Документ')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='document_acknowledgements', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Ознакомление с документом', + 'verbose_name_plural': 'Ознакомления с документами', + 'ordering': ['-acknowledged_at'], + 'unique_together': {('document', 'user')}, + }, + ), + ] diff --git a/backend/documents/migrations/__init__.py b/backend/documents/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/documents/models.py b/backend/documents/models.py new file mode 100644 index 00000000..0b3ac8db --- /dev/null +++ b/backend/documents/models.py @@ -0,0 +1,76 @@ +# documents/models.py + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.utils import timezone + +User = settings.AUTH_USER_MODEL # 'users.Employee' + + +class Document(models.Model): + """ + Документ, который нужно разослать сотрудникам. + """ + title = models.CharField(_('Название'), max_length=255) + file = models.FileField(_('Файл'), upload_to='documents/%Y/%m/%d/') + description = models.TextField(_('Описание'), blank=True) + + uploaded_by = models.ForeignKey( + User, + verbose_name=_('Загрузил'), + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='uploaded_documents' # <-- уникальный related_name + ) + uploaded_at = models.DateTimeField(_('Дата загрузки'), auto_now_add=True) + + sent_to_all = models.BooleanField( + _('Разослать всем'), + default=False, + help_text=_('Если включено — уведомление получат все активные сотрудники') + ) + recipients = models.ManyToManyField( + User, + verbose_name=_('Получатели'), + blank=True, + help_text=_('Если `Разослать всем` отключено, документ пойдет только этим'), + related_name='document_recipients' # <-- уникальный related_name + ) + + class Meta: + verbose_name = _('Документ') + verbose_name_plural = _('Документы') + ordering = ['-uploaded_at'] + + def __str__(self): + return self.title + + +class DocumentAcknowledgement(models.Model): + """ + Фиксация, что сотрудник ознакомился с документом. + """ + document = models.ForeignKey( + Document, + verbose_name=_('Документ'), + on_delete=models.CASCADE, + related_name='acknowledgements' + ) + user = models.ForeignKey( + User, + verbose_name=_('Сотрудник'), + on_delete=models.CASCADE, + related_name='document_acknowledgements' + ) + acknowledged_at = models.DateTimeField(_('Время ознакомления'), auto_now_add=True) + + class Meta: + verbose_name = _('Ознакомление с документом') + verbose_name_plural = _('Ознакомления с документами') + unique_together = ('document', 'user') + ordering = ['-acknowledged_at'] + + def __str__(self): + return f'{self.user} — {self.document} @ {self.acknowledged_at:%d.%m.%Y %H:%M}' diff --git a/backend/documents/notification.py b/backend/documents/notification.py new file mode 100644 index 00000000..a1a75db9 --- /dev/null +++ b/backend/documents/notification.py @@ -0,0 +1,98 @@ +import os +import logging + +# from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram import Bot +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from dotenv import load_dotenv +from asgiref.sync import async_to_sync + +from bots.models import BotSubscriber + +logger = logging.getLogger(__name__) +TG_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') + + +def notify_users_about_document(doc): + """ + Синхронная рассылка в Telegram при появлении + нового/обновлении существующего документа. + """ + logger.info( + f"[notify] Start notifying for Document id={doc.pk} title={doc.title!r}") + + # Список подписчиков + if doc.sent_to_all: + subs_qs = BotSubscriber.objects.filter( + telegram_id__isnull=False, + user__is_active=True + ) + else: + subs_qs = BotSubscriber.objects.filter( + telegram_id__isnull=False, + user__in=doc.recipients.filter(is_active=True) + ) + + count = subs_qs.count() + logger.info( + f"[notify] Found {count} subscribers (sent_to_all={doc.sent_to_all})") + if count == 0: + return + + # Telegram Bot + telegram_bot = Bot(token=TG_TOKEN) + + # клавиатура: ведёт пользователя в бот, в раздел «Документы» + kb = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="📄 Открыть документы", + callback_data="menu_docs") + ]]) + + text = ( + f"🆕 *Новый документ*: {doc.title}\n\n" + f"{doc.description or ''}\n\n" + f"Зайдите в раздел «Документы» в боте, чтобы скачать файл." + ) + # шлём всем подписчикам + for sub in subs_qs: + try: + async_to_sync(telegram_bot.send_message)( + chat_id=sub.telegram_id, + text=text, + parse_mode="Markdown", + disable_web_page_preview=True, + reply_markup=kb, + ) + except Exception as e: + # Записываем в лог, на каком именно подписчике и с каким текстом упало + logger.error( + f"[Telegram] Ошибка при отправке документу={doc.pk} " + f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}", + exc_info=True + ) + else: + logger.info( + f"[Telegram] Уведомление по документу={doc.pk} " + f"успешно отправлено пользователю={sub.user.pk}" + ) + + + + # # Шлём каждому + # for sub in subs_qs: + # tg_id = sub.telegram_id + # try: + # logger.info(f"[notify] Sending to tg_id={tg_id}") + # telegram_bot.send_message( + # chat_id=tg_id, + # text=text, + # parse_mode="Markdown", + # reply_markup=kb, + # disable_web_page_preview=True + # ) + # logger.info(f"[notify] → OK for tg_id={tg_id}") + # except Exception as e: + # logger.error( + # f"[notify] → FAILED for tg_id={tg_id}: {e}", exc_info=True) + + # logger.info(f"[notify] Done notifying for Document id={doc.pk}") diff --git a/backend/documents/signals.py b/backend/documents/signals.py new file mode 100644 index 00000000..ee1fabeb --- /dev/null +++ b/backend/documents/signals.py @@ -0,0 +1,41 @@ +# from django.db.models.signals import post_save, m2m_changed +# from django.dispatch import receiver +# from .models import Document +# # from .tasks import send_document_to_recipients + + +# # @receiver(post_save, sender=Document) +# # def on_document_saved(sender, instance: Document, created, **kwargs): +# # """ +# # При создании или обновлении документа — запускаем таск на рассылку. +# # """ +# # # Можно ограничить только created==True, но чаще полезно: +# # send_document_to_recipients(instance.id) + + +# # @receiver(m2m_changed, sender=Document.recipients.through) +# # def on_recipients_changed(sender, instance: Document, action, **kwargs): +# # """ +# # Если поменялись получатели (m2m) — тоже пересылаем. +# # """ +# # if action in ('post_add', 'post_remove', 'post_clear'): +# # send_document_to_recipients(instance.id) + + +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import Document +from .notification import notify_users_about_document + +logger = logging.getLogger(__name__) + +@receiver(post_save, sender=Document) +def on_document_saved(sender, instance: Document, created, **kwargs): + logger.info(f"[signals] Document Saved: id={instance.pk}, created={created}") + try: + notify_users_about_document(instance) + except Exception as e: + logger.exception(f"[signals] Error notifying users for document {instance.pk}: {e}") diff --git a/backend/documents/tasks.py b/backend/documents/tasks.py new file mode 100644 index 00000000..602e6167 --- /dev/null +++ b/backend/documents/tasks.py @@ -0,0 +1,177 @@ +# # import os +# # from dotenv import load_dotenv +# # from asgiref.sync import async_to_sync + +# # from aiogram import Bot +# # from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +# # from .models import Document +# # from django.contrib.auth import get_user_model +# # from bots.models import BotSubscriber +# # import logging + +# # logger = logging.getLogger(__name__) + + +# # load_dotenv() +# # BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") + +# # User = get_user_model() +# # telegram_bot = Bot(token=BOT_TOKEN) + + +# # def send_document_to_recipients(document_id: int): +# # """ +# # Уведомляем в Telegram о появлении нового документа: +# # — всем активным, если sent_to_all=True, +# # — или тем, кто в recipients. +# # """ +# # try: +# # doc = Document.objects.get(pk=document_id) +# # except Document.DoesNotExist: +# # return + +# # # собираем подписчиков +# # if doc.sent_to_all: +# # subs = BotSubscriber.objects.filter( +# # telegram_id__isnull=False, +# # user__is_active=True +# # ) +# # else: +# # subs = BotSubscriber.objects.filter( +# # telegram_id__isnull=False, +# # user__in=doc.recipients.filter(is_active=True) +# # ) + +# # if not subs.exists(): +# # return + +# # # клавиатура: ведёт пользователя в бот, в раздел «Документы» +# # kb = InlineKeyboardMarkup(inline_keyboard=[[ +# # InlineKeyboardButton(text="📄 Открыть документы", +# # callback_data="menu_docs") +# # ]]) + +# # text = ( +# # f"🆕 *Новый документ*: {doc.title}\n\n" +# # f"{doc.description or ''}\n\n" +# # f"Зайдите в раздел «Документы» в боте, чтобы скачать файл." +# # ) + +# # # шлём всем подписчикам +# # for sub in subs: +# # try: +# # async_to_sync(telegram_bot.send_message)( +# # chat_id=sub.telegram_id, +# # text=text, +# # parse_mode="Markdown", +# # disable_web_page_preview=True, +# # reply_markup=kb, +# # ) +# # except Exception as e: +# # # Записываем в лог, на каком именно подписчике и с каким текстом упало +# # logger.error( +# # f"[Telegram] Ошибка при отправке документу={doc.pk} " +# # f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}", +# # exc_info=True +# # ) +# # else: +# # logger.info( +# # f"[Telegram] Уведомление по документу={doc.pk} " +# # f"успешно отправлено пользователю={sub.user.pk}" +# # ) + + +# # documents/tasks.py +# import os +# from dotenv import load_dotenv +# from celery import shared_task +# from django.conf import settings +# from django.urls import reverse +# from django.utils import timezone +# from telegram import Bot as TgBot, InlineKeyboardButton, InlineKeyboardMarkup + +# from .models import Document +# from django.contrib.auth import get_user_model +# from bots.models import BotSubscriber +# import logging + +# logger = logging.getLogger(__name__) + +# load_dotenv() +# TG_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') or settings.TELEGRAM_BOT_TOKEN + +# User = get_user_model() + + +# # @shared_task(bind=True, name="documents.send_document_to_recipients") +# def send_document_to_recipients(document_id): +# try: +# doc = Document.objects.get(pk=document_id) +# except Document.DoesNotExist: +# return + +# # Собираем подписчиков +# if doc.sent_to_all: +# subs = BotSubscriber.objects.filter( +# telegram_id__isnull=False, +# user__is_active=True, +# ) +# else: +# subs = BotSubscriber.objects.filter( +# telegram_id__isnull=False, +# user__in=doc.recipients.filter(is_active=True), +# ) +# if not subs.exists(): +# return + +# # Синхронный телеграм-бот +# bot = TgBot(token=TG_TOKEN) + +# # Кнопка «Открыть документы» +# kb = InlineKeyboardMarkup( +# [[ InlineKeyboardButton("📄 Открыть документы", callback_data="menu_docs") ]] +# ) + +# # Текст уведомления +# text = ( +# f"🆕 *Новый документ*: {doc.title}\n\n" +# f"{doc.description or ''}\n\n" +# f"Дата публикации: {doc.uploaded_at.strftime('%d.%m.%Y %H:%M')}" +# ) + +# # for sub in subs: +# # try: +# # bot.send_message( +# # chat_id=sub.telegram_id, +# # text=text, +# # parse_mode="Markdown", +# # reply_markup=kb, +# # disable_web_page_preview=True +# # ) +# # except Exception as e: +# # print(e) +# # логируем, чтобы понимать, кому не дошло +# # self.retry(exc=e, countdown=60, max_retries=3) + +# for sub in subs: +# try: +# bot.send_message( +# chat_id=sub.telegram_id, +# text=text, +# parse_mode="Markdown", +# disable_web_page_preview=True, +# reply_markup=kb, +# ) +# except Exception as e: +# # Записываем в лог, на каком именно подписчике и с каким текстом упало +# logger.error( +# f"[Telegram] Ошибка при отправке документу={doc.pk} " +# f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}", +# exc_info=True +# ) +# else: +# logger.info( +# f"[Telegram] Уведомление по документу={doc.pk} " +# f"успешно отправлено пользователю={sub.user.pk}" +# ) diff --git a/backend/documents/tests.py b/backend/documents/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/documents/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/documents/urls.py b/backend/documents/urls.py new file mode 100644 index 00000000..222ceb34 --- /dev/null +++ b/backend/documents/urls.py @@ -0,0 +1,10 @@ +# documents/urls.py +from django.urls import path +from .views import DocumentListView, acknowledge_document + +app_name = 'documents' + +urlpatterns = [ + path('', DocumentListView.as_view(), name='document_list'), + path('ack//', acknowledge_document, name='acknowledge'), +] diff --git a/backend/documents/views.py b/backend/documents/views.py new file mode 100644 index 00000000..899f9341 --- /dev/null +++ b/backend/documents/views.py @@ -0,0 +1,39 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q +from django.views.generic import ListView +from .models import Document, DocumentAcknowledgement + + +class DocumentListView(LoginRequiredMixin, ListView): + model = Document + template_name = 'documents/document_list.html' + context_object_name = 'documents' + paginate_by = 20 + + def get_queryset(self): + user = self.request.user + return ( + Document.objects + .filter(Q(sent_to_all=True) | Q(recipients=user)) + .distinct() + .order_by('-uploaded_at') + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + user = self.request.user + # получаем id документов, с которыми пользователь уже ознакомился + acked = DocumentAcknowledgement.objects.filter(user=user) \ + .values_list('document_id', flat=True) + ctx['acked_ids'] = set(acked) + return ctx + +from django.shortcuts import get_object_or_404, redirect +from django.contrib.auth.decorators import login_required + + +@login_required +def acknowledge_document(request, pk): + doc = get_object_or_404(Document, pk=pk) + DocumentAcknowledgement.objects.get_or_create(document=doc, user=request.user) + return redirect('documents:document_list') diff --git a/backend/eusrr_backend/__init__.py b/backend/eusrr_backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/eusrr_backend/asgi.py b/backend/eusrr_backend/asgi.py new file mode 100644 index 00000000..bc88cd16 --- /dev/null +++ b/backend/eusrr_backend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for eusrr_backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') + +application = get_asgi_application() diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py new file mode 100644 index 00000000..824f2f56 --- /dev/null +++ b/backend/eusrr_backend/settings.py @@ -0,0 +1,184 @@ +from pathlib import Path +import os +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + + +SECRET_KEY = 'django-insecure--kftc-d47!6m2ib6jof3^d%n1&4k%mdf33brejsyg*@il#$-52' + +DEBUG = True + +ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django_bootstrap5', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users.apps.UsersConfig', + 'api.apps.ApiConfig', + 'hikcentral.apps.HikcentralConfig', + 'calendar_app.apps.CalendarAppConfig', + 'documents.apps.DocumentsConfig', + 'bots', + +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'eusrr_backend.urls' + +TEMPLATES_DIR = BASE_DIR / 'templates' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [TEMPLATES_DIR], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'eusrr_backend.wsgi.application' + + +USE_SQLITE = os.getenv('USE_SQLITE', 'False').lower() == 'true' + +DATABASES = { + 'default': {}, + 'hikcentral': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'cms', # имя базы HikCentral, смотри по факту + 'USER': 'postgres', # имя пользователя + 'PASSWORD': None, # пароль, если не менял (или актуальный) + 'HOST': '127.0.0.1', + 'PORT': '5432', # или нужный порт + 'OPTIONS': { + 'options': '-c search_path=platform' + } + } +} + +if USE_SQLITE: + # Конфигурация для SQLite + DATABASES['default'] = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +else: + # Конфигурация для PostgreSQL + DATABASES['default'] = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('POSTGRES_DB', 'django'), + 'USER': os.getenv('POSTGRES_USER', 'django'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''), + 'HOST': os.getenv('DB_HOST', ''), + 'PORT': os.getenv('DB_PORT', 5432) + } + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +LANGUAGE_CODE = 'ru' + +TIME_ZONE = 'Europe/Moscow' + +USE_I18N = True + +USE_TZ = True + + +STATIC_URL = 'static/' + + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'users.Employee' + +LOGIN_URL = 'register' +LOGIN_REDIRECT_URL = 'profile' +LOGOUT_REDIRECT_URL = 'login' + + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "[{levelname}] {asctime} {name}:{lineno} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "loggers": { + # собственные приложения + "documents": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + "bots": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + # всё, что делает Django + "django": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + # и библиотека aiogram + "aiogram": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, +} + diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py new file mode 100644 index 00000000..58a81050 --- /dev/null +++ b/backend/eusrr_backend/urls.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from django.urls import include, path, reverse_lazy +from django.views.generic.edit import CreateView +from users.forms import RegistrationForm +from users.views import profile, avatar_remove +from django.conf.urls.static import static +from django.conf import settings + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('auth/', include('django.contrib.auth.urls')), + path('auth/register/', CreateView.as_view( + template_name='registration/register.html', + form_class=RegistrationForm, + success_url=reverse_lazy('profile') + ), name='register'), + path('avatar/remove/', avatar_remove, name='avatar_remove'), + path('calendar/', include('calendar_app.urls', namespace='calendar')), + path('documents/', include('documents.urls', namespace='documents')), + path('', profile, name='profile'), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/eusrr_backend/wsgi.py b/backend/eusrr_backend/wsgi.py new file mode 100644 index 00000000..2c5bfa36 --- /dev/null +++ b/backend/eusrr_backend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for eusrr_backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') + +application = get_wsgi_application() diff --git a/backend/hikcentral/__init__.py b/backend/hikcentral/__init__.py new file mode 100644 index 00000000..5e0902da --- /dev/null +++ b/backend/hikcentral/__init__.py @@ -0,0 +1 @@ +default_app_config = 'hikcentral.apps.HikcentralConfig' diff --git a/backend/hikcentral/admin.py b/backend/hikcentral/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/hikcentral/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/hikcentral/apps.py b/backend/hikcentral/apps.py new file mode 100644 index 00000000..5f724df6 --- /dev/null +++ b/backend/hikcentral/apps.py @@ -0,0 +1,10 @@ +# hikcentral/apps.py + +from django.apps import AppConfig + + +class HikcentralConfig(AppConfig): + name = 'hikcentral' + + def ready(self): + import hikcentral.signals diff --git a/backend/hikcentral/migrations/0001_initial.py b/backend/hikcentral/migrations/0001_initial.py new file mode 100644 index 00000000..53a47632 --- /dev/null +++ b/backend/hikcentral/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.4 on 2025-07-24 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HikPerson', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('person_group_id', models.IntegerField(blank=True, null=True)), + ('person_type', models.IntegerField(blank=True, null=True)), + ('person_code', models.CharField(max_length=16, unique=True)), + ('given_name', models.CharField(blank=True, max_length=255, null=True)), + ('family_name', models.CharField(blank=True, max_length=255, null=True)), + ('full_name', models.CharField(blank=True, max_length=255, null=True)), + ('gender', models.IntegerField(blank=True, null=True)), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('phone_number', models.CharField(blank=True, max_length=32, null=True)), + ('photo_url', models.CharField(blank=True, max_length=512, null=True)), + ('photo_index', models.IntegerField(blank=True, null=True)), + ('small_photo_url', models.CharField(blank=True, max_length=512, null=True)), + ('start_valid_date', models.DateTimeField(blank=True, null=True)), + ('end_valid_date', models.DateTimeField(blank=True, null=True)), + ('remark', models.TextField(blank=True, null=True)), + ('update_time', models.DateTimeField(blank=True, null=True)), + ('create_time', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.IntegerField(default=0)), + ('object_guid', models.CharField(blank=True, max_length=64, null=True)), + ('person_from', models.IntegerField(blank=True, null=True)), + ('rdn', models.CharField(blank=True, max_length=255, null=True)), + ('dn', models.CharField(blank=True, max_length=255, null=True)), + ('usn_changed', models.BigIntegerField(blank=True, null=True)), + ('start_time_differ', models.IntegerField(blank=True, null=True)), + ('end_time_differ', models.IntegerField(blank=True, null=True)), + ], + options={ + 'db_table': 'person', + 'managed': False, + }, + ), + migrations.CreateModel( + name='HikPersonHeadPic', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('standard_head_portrait', models.TextField(blank=True, null=True)), + ('thumbnail_head_portrait', models.TextField(blank=True, null=True)), + ('deepmode_head_portrait', models.TextField(blank=True, null=True)), + ('identi_photo', models.TextField(blank=True, null=True)), + ('create_time', models.DateTimeField(blank=True, null=True)), + ('update_time', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'person_head_pic', + 'managed': False, + }, + ), + ] diff --git a/backend/hikcentral/migrations/__init__.py b/backend/hikcentral/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/hikcentral/models.py b/backend/hikcentral/models.py new file mode 100644 index 00000000..f870a399 --- /dev/null +++ b/backend/hikcentral/models.py @@ -0,0 +1,68 @@ +# hikcentral/models.py + +from django.db import models + + +class HikPerson(models.Model): + id = models.AutoField(primary_key=True) + person_group_id = models.IntegerField(blank=True, null=True) + person_type = models.IntegerField(blank=True, null=True) + # важно! Уникальный ID от 1 до 16 символов (цифры/буквы) + person_code = models.CharField(max_length=16, unique=True) + given_name = models.CharField(max_length=255, blank=True, null=True) + family_name = models.CharField(max_length=255, blank=True, null=True) + full_name = models.CharField(max_length=255, blank=True, null=True) + gender = models.IntegerField(blank=True, null=True) + email = models.EmailField(blank=True, null=True) + phone_number = models.CharField(max_length=32, blank=True, null=True) + photo_url = models.CharField( + max_length=512, blank=True, null=True, default="") + photo_index = models.IntegerField(blank=True, null=True) + small_photo_url = models.CharField(max_length=512, blank=True, null=True) + start_valid_date = models.DateTimeField(blank=True, null=True) + end_valid_date = models.DateTimeField(blank=True, null=True) + remark = models.TextField(blank=True, null=True) + update_time = models.DateTimeField(blank=True, null=True) + create_time = models.DateTimeField(blank=True, null=True) + is_deleted = models.IntegerField(default=0) + object_guid = models.CharField(max_length=64, blank=True, null=True) + person_from = models.IntegerField(blank=True, null=True) + rdn = models.CharField(max_length=255, blank=True, null=True) + dn = models.CharField(max_length=255, blank=True, null=True) + usn_changed = models.BigIntegerField(blank=True, null=True) + start_time_differ = models.IntegerField(blank=True, null=True) + end_time_differ = models.IntegerField(blank=True, null=True) + + class Meta: + db_table = 'person' + app_label = 'hikcentral' + managed = False # очень важно! — Django не управляет этой таблицей + + def __str__(self): + return self.full_name or self.person_code + + +class HikPersonHeadPic(models.Model): + id = models.AutoField(primary_key=True) + person = models.ForeignKey( + HikPerson, + db_column='person_id', + on_delete=models.CASCADE, + related_name='head_pics' + ) + standard_head_portrait = models.TextField( + blank=True, null=True) # основное фото (base64-строка) + thumbnail_head_portrait = models.TextField( + blank=True, null=True) # миниатюра (base64) + deepmode_head_portrait = models.TextField(blank=True, null=True) + identi_photo = models.TextField(blank=True, null=True) + create_time = models.DateTimeField(blank=True, null=True) + update_time = models.DateTimeField(blank=True, null=True) + + class Meta: + db_table = 'person_head_pic' + app_label = 'hikcentral' + managed = False + + def __str__(self): + return f"Photo for person_id={self.person_id}" diff --git a/backend/hikcentral/signals.py b/backend/hikcentral/signals.py new file mode 100644 index 00000000..b9bd3f7e --- /dev/null +++ b/backend/hikcentral/signals.py @@ -0,0 +1,77 @@ +# # backend/hikcentral/signals.py + +# from django.db.models.signals import post_save, post_delete +# from django.dispatch import receiver +# from users.models import Employee +# from .models import HikPerson, HikPersonHeadPic +# from django.utils import timezone +# from .utils import gen_person_code, compress_image_to_base64 + + +# @receiver(post_save, sender=Employee) +# def sync_employee_to_hikcentral(sender, instance, created, **kwargs): +# db = 'hikcentral' + +# # Определяем person_code (уникальный) +# person_code = gen_person_code(instance) + +# # Проставим даты +# now = timezone.now() +# start_valid = now +# end_valid = now.replace(year=now.year+10) + +# # 1. Создаём/обновляем запись в person +# person_values = dict( +# person_group_id=1, +# person_type=0, +# person_code=person_code, +# given_name=instance.first_name, +# family_name=instance.last_name, +# full_name=f"{instance.first_name} {instance.last_name}", +# gender=0, # по аналогии: 0 - не указан, 1 - муж, 2 - жен +# email=instance.email, +# phone_number=str( +# instance.phone_number) if instance.phone_number else "", +# photo_url=instance.avatar.url if instance.avatar else "", +# photo_index=0, +# small_photo_url=instance.avatar.url if instance.avatar else "", +# start_valid_date=start_valid, +# end_valid_date=end_valid, +# remark='', +# update_time=now, +# create_time=now, +# is_deleted=0, +# person_from=0, +# usn_changed=0, +# start_time_differ=10800, +# end_time_differ=10800, +# ) + +# # Сохраняем сотрудника в HikCentral +# person = HikPerson.objects.using(db).create(**person_values) + +# # 2. Картинка (photo/avatar) +# if instance.avatar: +# img_b64 = compress_image_to_base64(instance.avatar) +# HikPersonHeadPic.objects.using(db).create( +# person_id=person.id, +# standard_head_portrait=img_b64, +# thumbnail_head_portrait='', +# deepmode_head_portrait='', +# identi_photo='', +# create_time=now, +# update_time=now, +# ) + + +# @receiver(post_delete, sender=Employee) +# def delete_employee_from_hikcentral(sender, instance, **kwargs): +# db = 'hikcentral' +# # Найдём всех по имени и почте — или храни person_code в Employee +# HikPerson.objects.using(db).filter( +# email=instance.email, +# full_name=f"{instance.first_name} {instance.last_name}", +# ).delete() +# # Не забудь удалить фото +# # person_ids = list(...) +# # HikPersonHeadPic.objects.using(db).filter(person_id__in=person_ids).delete() diff --git a/backend/hikcentral/tests.py b/backend/hikcentral/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/hikcentral/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/hikcentral/utils.py b/backend/hikcentral/utils.py new file mode 100644 index 00000000..f2186f6b --- /dev/null +++ b/backend/hikcentral/utils.py @@ -0,0 +1,50 @@ +import base64 +from PIL import Image +from io import BytesIO +import random +import string + + +def gen_person_code(instance): + import re + base = str(instance.phone_number or instance.email or instance.pk) + code = re.sub(r'\W+', '', base)[:16] + + # Проверяем уникальность, если не уникален — добавляем символы в конец + from .models import HikPerson + existing = ( + HikPerson.objects.using('hikcentral') + .filter(person_code=code) + .exists() + ) + orig_code = code + while existing: + # Укорачиваем до 12 символов и дописываем случайные 4 символа + code = ( + orig_code[:12] + ''.join(random.choices(string.ascii_letters + string.digits, k=4)))[:16] + existing = ( + HikPerson.objects.using('hikcentral') + .filter(person_code=code) + .exists() + ) + return code + + +def compress_image_to_base64(image_field, target_size_kb=200): + """Сжимает изображение до размера (примерно) target_size_kb и возвращает base64-строку.""" + if not image_field: + return '' + image = Image.open(image_field) + output = BytesIO() + quality = 90 + # Сжимать до ~200КБ + while True: + output.seek(0) + image.save(output, format='JPEG', quality=quality) + size_kb = output.tell() / 1024 + if size_kb <= target_size_kb or quality <= 20: + break + quality -= 10 + output.seek(0) + img_b64 = base64.b64encode(output.read()).decode('utf-8') + return img_b64 diff --git a/backend/hikcentral/views.py b/backend/hikcentral/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/hikcentral/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 00000000..93246a42 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..e88180df --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +Django==5.2.4 +djangorestframework==3.16.0 +django-extensions==4.1 +django-phonenumber-field==8.1.0 +django-bootstrap5==25.1 +dj-database-url==3.0.1 +djoser==2.3.3 +dotenv==0.9.9 +phonenumbers==9.0.10 +pillow==11.3.0 +psycopg2-binary==2.9.10 +python-telegram-bot==22.3 +aiogram==3.21.0 +APScheduler==3.11.0 +celery==5.5.3 diff --git a/backend/templates/base.html b/backend/templates/base.html new file mode 100644 index 00000000..2e417398 --- /dev/null +++ b/backend/templates/base.html @@ -0,0 +1,143 @@ + + + + + + {% block title %}EUSRR{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + +{% if user.is_authenticated %} + +{% endif %} + +{% block extra_js %}{% endblock %} + + + diff --git a/backend/templates/calendar/calendar.html b/backend/templates/calendar/calendar.html new file mode 100644 index 00000000..b4d66479 --- /dev/null +++ b/backend/templates/calendar/calendar.html @@ -0,0 +1,23 @@ + +{% extends "base.html" %} +{% block content %} +
+ + + +{% endblock %} diff --git a/backend/templates/documents/document_list.html b/backend/templates/documents/document_list.html new file mode 100644 index 00000000..01fec6d2 --- /dev/null +++ b/backend/templates/documents/document_list.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Документы{% endblock %} +{% block content %} +

Документы

+ + {% if documents %} + + {# пагинация, как было ранее #} + {% else %} +

Нет доступных документов.

+ {% endif %} +{% endblock %} diff --git a/backend/templates/profile.html b/backend/templates/profile.html new file mode 100644 index 00000000..48e05950 --- /dev/null +++ b/backend/templates/profile.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} +{% load static django_bootstrap5 %} + +{% block content %} +
+
+ +
+
+
+ {% if user.avatar and user.avatar.url %} + Аватар {{ user.first_name }} {{ user.last_name }} + {% else %} +
+ +
+ {% endif %} +
+
+
+ + +
+
+
+

Редактирование профиля

+ +
+ {% csrf_token %} +
+ {% bootstrap_form form layout='horizontal' %} +
+
+ + + Отмена + +
+
+
+
+
+
+ + + {% comment %} + Показываем, только если в контексте есть bot_sub и Telegram ещё не привязан + {% endcomment %} + {% if bot_sub and not bot_sub.telegram_id %} +
+
+
+ Для привязки Telegram: + + Привязать Telegram + +
+
+
+ {% endif %} + {% if bot_sub and not bot_sub.whatsapp_id %} +
+
+
+ Для привязки WhatsApp: + + Привязать WhatsApp + +
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/registration/login.html b/backend/templates/registration/login.html new file mode 100644 index 00000000..bb44f718 --- /dev/null +++ b/backend/templates/registration/login.html @@ -0,0 +1,93 @@ +{% extends 'base.html' %} +{% load static django_bootstrap5 %} + +{% block title %}Авторизация{% endblock %} + +{% block content %} +
+
+
+
+

Вход в систему

+
+
+
+ {% csrf_token %} + + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} + +
+ {% endif %} + + +
+ + + {% if form.username.errors %} +
+ {{ form.username.errors|striptags }} +
+ {% endif %} +
+ + +
+ + + + {% if form.password.errors %} +
+ {{ form.password.errors|striptags }} +
+ {% endif %} +
+ +
+ + Забыли пароль? +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/registration/logout.html b/backend/templates/registration/logout.html new file mode 100644 index 00000000..d2ffab58 --- /dev/null +++ b/backend/templates/registration/logout.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% load bootstrap5 %} + +{% block title %}Выход из системы{% endblock %} + +{% block content %} +
+
+
+
+

Выход из системы

+
+ +
+
+ {% csrf_token %} + +
+

Вы уверены, что хотите выйти из системы?

+
+ +
+ + Остаться +
+
+
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_change.html b/backend/templates/registration/password_change.html new file mode 100644 index 00000000..2f5a4bda --- /dev/null +++ b/backend/templates/registration/password_change.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Смена пароля{% endblock %} + +{% block content %} +
+
+
+
+

Смена пароля

+
+
+
+ {% csrf_token %} +
+
+ {{ form.old_password }} + +
+
+
+
+ {{ form.new_password1 }} + +
+
+
+
+ {{ form.new_password2 }} + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_change_done.html b/backend/templates/registration/password_change_done.html new file mode 100644 index 00000000..7acf0a17 --- /dev/null +++ b/backend/templates/registration/password_change_done.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Пароль изменен{% endblock %} + +{% block content %} +
+
+
+
+

Успешная смена пароля

+
+
+ +

Ваш пароль был успешно изменен!

+ + Войти с новым паролем + +
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_reset.html b/backend/templates/registration/password_reset.html new file mode 100644 index 00000000..6ef30a79 --- /dev/null +++ b/backend/templates/registration/password_reset.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Сброс пароля{% endblock %} + +{% block content %} +
+
+
+
+

Сброс пароля

+
+
+
+ {% csrf_token %} +
+
+ {{ form.email }} + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_reset_complete.html b/backend/templates/registration/password_reset_complete.html new file mode 100644 index 00000000..73d0eec0 --- /dev/null +++ b/backend/templates/registration/password_reset_complete.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Пароль восстановлен{% endblock %} + +{% block content %} +
+
+
+
+

Пароль успешно изменен

+
+
+ +

Ваш новый пароль был установлен. Теперь вы можете войти в систему.

+ + Войти в систему + +
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_reset_confirm.html b/backend/templates/registration/password_reset_confirm.html new file mode 100644 index 00000000..449afdb0 --- /dev/null +++ b/backend/templates/registration/password_reset_confirm.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Новый пароль{% endblock %} + +{% block content %} +
+
+
+
+

Установка нового пароля

+
+
+
+ {% csrf_token %} +
+
+ {{ form.new_password1 }} + +
+
+
+
+ {{ form.new_password2 }} + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/password_reset_done.html b/backend/templates/registration/password_reset_done.html new file mode 100644 index 00000000..74a8453b --- /dev/null +++ b/backend/templates/registration/password_reset_done.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Письмо отправлено{% endblock %} + +{% block content %} +
+
+
+
+

Инструкции отправлены

+
+
+ +

+ На ваш email отправлены инструкции по сбросу пароля. + Проверьте почту и следуйте указаниям в письме. +

+ + Вернуться к авторизации + +
+
+
+
+{% endblock %} diff --git a/backend/templates/registration/register.html b/backend/templates/registration/register.html new file mode 100644 index 00000000..4401cad7 --- /dev/null +++ b/backend/templates/registration/register.html @@ -0,0 +1,121 @@ +{% extends 'base.html' %} +{% load static django_bootstrap5 %} + +{% block title %}Регистрация сотрудника{% endblock %} + +{% block content %} +
+
+
+
+

Регистрация нового сотрудника

+
+ +
+
+ {% csrf_token %} + + +
+
+ {% bootstrap_field form.email show_label=False placeholder="E-mail" %} + Пример: ivanov@company.com +
+
+ {% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %} +
+
+ + +
+
+ {% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %} + + + +
+
+ {% bootstrap_field form.telegram show_label=False placeholder="Telegram" %} + + + +
+ {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} + +
+ {% endif %} +
+ + +
+ {% for field in form %} + {% if field.name == 'last_name' or field.name == 'first_name' or field.name == 'patronymic' %} +
+ {% bootstrap_field field show_label=False %} +
+ {% endif %} + {% endfor %} +
+ {% if form.birth_date %} +
+
+ {% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %} +
+
+ {% endif %} + + +
+
+ {% bootstrap_field form.password1 show_label=False placeholder="Пароль" %} + + + +
+
+ {% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %} + + + +
+
+ + +
+ {% bootstrap_field form.avatar %} + Рекомендуемый размер: 200x200 px +
+ +
+ + Уже есть аккаунт? Войти +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/users/admin.py b/backend/users/admin.py new file mode 100644 index 00000000..0ef20c74 --- /dev/null +++ b/backend/users/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Employee, EmployeeAction + + +@admin.register(Employee) +class EmployeeAdmin(admin.ModelAdmin): + list_display = ( + 'last_name', 'first_name', 'phone_number', 'position', 'employment_status', 'is_actually_active' + ) + list_filter = ('position',) + search_fields = ('last_name', 'first_name', 'phone_number', 'email') + readonly_fields = ('created_at', 'updated_at') + ordering = ('last_name', 'first_name') + fieldsets = ( + (None, { + 'fields': ('first_name', 'last_name', 'patronymic', 'gender', 'avatar', 'birth_date') + }), + (_('Contact Info'), { + 'fields': ('phone_number', 'email', 'telegram', 'whatsapp') + }), + (_('Job'), { + 'fields': ('position',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + +@admin.register(EmployeeAction) +class EmployeeActionAdmin(admin.ModelAdmin): + list_display = ('employee', 'action', 'date', 'comment') + list_filter = ('action', 'date') + search_fields = ('employee__last_name', 'employee__first_name', 'comment') + date_hierarchy = 'date' + ordering = ('-date',) + + def save_model(self, request, obj, form, change): + # При создании или обновлении записи автоматически обновляем статус сотрудника + super().save_model(request, obj, form, change) \ No newline at end of file diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 00000000..9677be11 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,9 @@ +# users/apps.py +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' + + def ready(self): + import users.signals # noqa diff --git a/backend/users/constants.py b/backend/users/constants.py new file mode 100644 index 00000000..cc63030a --- /dev/null +++ b/backend/users/constants.py @@ -0,0 +1,5 @@ +GENDER_CHOICES = ( + (1, 'Мужской'), + (2, 'Женский'), + (0, 'Не указан'), +) diff --git a/backend/users/forms.py b/backend/users/forms.py new file mode 100644 index 00000000..18744371 --- /dev/null +++ b/backend/users/forms.py @@ -0,0 +1,84 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm, UserChangeForm +from phonenumber_field.formfields import PhoneNumberField +from .models import Employee + + +class AvatarValidationMixin: + def clean_avatar(self): + avatar = self.cleaned_data.get('avatar') + if avatar: + if avatar.size > 2 * 1024 * 1024: + raise forms.ValidationError( + "Размер файла не должен превышать 2MB") + if not avatar.name.lower().endswith(('.jpg', '.jpeg', '.png')): + raise forms.ValidationError( + "Допустимые форматы: JPG, JPEG, PNG") + return avatar + + +class RegistrationForm(AvatarValidationMixin, UserCreationForm): + first_name = forms.CharField( + label="Имя", + required=True, + widget=forms.TextInput(attrs={'required': 'required'}) + ) + last_name = forms.CharField( + label="Фамилия", + required=True, + widget=forms.TextInput(attrs={'required': 'required'}) + ) + email = forms.EmailField( + label="Email", + required=True, + widget=forms.EmailInput(attrs={'required': 'required'}) + ) + avatar = forms.ImageField( + label="Аватар", + required=True, + widget=forms.FileInput(attrs={'required': 'required'}) + ) + + class Meta: + model = Employee + fields = [ + 'phone_number', # USERNAME_FIELD + 'last_name', + 'first_name', + 'patronymic', + 'birth_date', + 'whatsapp', + 'telegram', + 'email', + 'avatar', + 'password1', + 'password2' + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['whatsapp'].required = False + self.fields['telegram'].required = False + + +class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm): + avatar = forms.ImageField(label='Аватарка', required=False, + widget=forms.ClearableFileInput(attrs={'id': 'avatarInput'})) + telegram = forms.CharField(label='Telegram', required=False) + whatsapp = PhoneNumberField(label='WhatsApp', required=False) + + class Meta: + model = Employee + fields = ( + 'last_name', + 'first_name', + 'patronymic', + 'birth_date', + 'gender', + 'email', + 'position', + 'phone_number', + 'telegram', + 'whatsapp', + 'avatar' + ) diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 00000000..7c1bf611 --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.4 on 2025-07-22 06:40 + +import django.contrib.auth.models +import django.utils.timezone +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Employee', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('avatar', models.ImageField(upload_to='users/avatars', verbose_name='Фото')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=100, region=None, unique=True, verbose_name='Номер телефона')), + ('patronymic', models.CharField(blank=True, max_length=100, verbose_name='Отчество')), + ('position', models.CharField(max_length=255, verbose_name='Должность')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), + ('telegram', models.CharField(blank=True, max_length=100, verbose_name='Telegram')), + ('whatsapp', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='WhatsApp')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Сотрудник', + 'verbose_name_plural': 'Сотрудники', + 'ordering': ('last_name', 'first_name'), + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/users/migrations/0002_employee_gender.py b/backend/users/migrations/0002_employee_gender.py new file mode 100644 index 00000000..c668c2db --- /dev/null +++ b/backend/users/migrations/0002_employee_gender.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-07-24 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='gender', + field=models.PositiveSmallIntegerField(choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол'), + ), + ] diff --git a/backend/users/migrations/0003_employee_birth_date_employee_created_at_and_more.py b/backend/users/migrations/0003_employee_birth_date_employee_created_at_and_more.py new file mode 100644 index 00000000..7cd716cf --- /dev/null +++ b/backend/users/migrations/0003_employee_birth_date_employee_created_at_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-07-25 06:35 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_employee_gender'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='birth_date', + field=models.DateField(blank=True, null=True, verbose_name='Дата рождения'), + ), + migrations.AddField( + model_name='employee', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='employee', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.CreateModel( + name='EmployeeAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие')), + ('date', models.DateTimeField(verbose_name='Дата действия')), + ('comment', models.TextField(blank=True, verbose_name='Комментарий/причина')), + ('extra', models.JSONField(blank=True, null=True, verbose_name='Дополнительно')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Кадровое событие', + 'verbose_name_plural': 'Кадровые события', + 'ordering': ['-date'], + }, + ), + ] diff --git a/backend/users/migrations/0004_alter_employee_managers.py b/backend/users/migrations/0004_alter_employee_managers.py new file mode 100644 index 00000000..5fffc9bb --- /dev/null +++ b/backend/users/migrations/0004_alter_employee_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.4 on 2025-07-29 05:31 + +import users.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_employee_birth_date_employee_created_at_and_more'), + ] + + operations = [ + migrations.AlterModelManagers( + name='employee', + managers=[ + ('objects', users.models.EmployeeManager()), + ], + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 00000000..8aa3ce65 --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,142 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.base_user import BaseUserManager +from phonenumber_field.modelfields import PhoneNumberField +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from .constants import GENDER_CHOICES + + +class EmployeeManager(BaseUserManager): + use_in_migrations = True + + def create_user(self, phone_number, email, password=None, **extra_fields): + """ + Создает и сохраняет обычного сотрудника с переданным номером телефона и email. + """ + if not phone_number: + raise ValueError(_('Пользователь должен иметь номер телефона')) + if not email: + raise ValueError(_('Пользователь должен иметь email')) + email = self.normalize_email(email) + user = self.model(phone_number=phone_number, + email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, phone_number, email, password=None, **extra_fields): + """ + Создает и сохраняет суперпользователя. + """ + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError(_('Суперпользователь должен иметь is_staff=True')) + if extra_fields.get('is_superuser') is not True: + raise ValueError( + _('Суперпользователь должен иметь is_superuser=True')) + + return self.create_user(phone_number, email, password, **extra_fields) + + +class Employee(AbstractUser): + gender = models.PositiveSmallIntegerField( + _('Пол'), choices=GENDER_CHOICES, default=0) + avatar = models.ImageField(_('Фото'), upload_to='users/avatars') + phone_number = PhoneNumberField( + 'Номер телефона', max_length=100, unique=True, blank=False) + patronymic = models.CharField(_('Отчество'), max_length=100, blank=True) + birth_date = models.DateField(_('Дата рождения'), blank=True, null=True) + position = models.CharField(_('Должность'), max_length=255) + email = models.EmailField(_('email address'), unique=True, blank=False) + telegram = models.CharField( + 'Telegram', max_length=100, blank=True) + whatsapp = PhoneNumberField('WhatsApp', blank=True) + username = None + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = EmployeeManager() + + USERNAME_FIELD = 'phone_number' + REQUIRED_FIELDS = ['first_name', 'last_name', 'email'] + + class Meta: + verbose_name = 'Сотрудник' + verbose_name_plural = 'Сотрудники' + ordering = ('last_name', 'first_name') + + def clean(self): + super().clean() + if not self.whatsapp and not self.telegram: + raise ValidationError( + 'Заполните хотя бы одно из полей: WhatsApp или Telegram', + code='contact_required' + ) + + def __str__(self): + return f'{self.last_name} {self.first_name}' + + @property + def employment_status(self): + """Текущий статус сотрудника (последнее кадровое событие)""" + last_action = self.actions.order_by('-date').first() + if last_action: + return last_action.get_action_display() + return 'Нет данных' + + @property + def is_actually_active(self): + """Сотрудник считается работающим по последнему действию""" + last_action = self.actions.order_by('-date').first() + if not last_action: + return False + return last_action.action in [ + EmployeeAction.ACTION_HIRED, + EmployeeAction.ACTION_REHIRED, + EmployeeAction.ACTION_RETURNED_FROM_LEAVE, + EmployeeAction.ACTION_RETURNED_FROM_MATERNITY, + EmployeeAction.ACTION_TRANSFERRED, + ] + + +class EmployeeAction(models.Model): + ACTION_HIRED = 'hired' + ACTION_DISMISSED = 'dismissed' + ACTION_ON_LEAVE = 'on_leave' + ACTION_RETURNED_FROM_LEAVE = 'returned_from_leave' + ACTION_ON_MATERNITY = 'on_maternity' + ACTION_RETURNED_FROM_MATERNITY = 'returned_from_maternity' + ACTION_TRANSFERRED = 'transferred' + ACTION_REHIRED = 'rehired' + + ACTION_CHOICES = [ + (ACTION_HIRED, 'Принят'), + (ACTION_DISMISSED, 'Уволен'), + (ACTION_ON_LEAVE, 'В отпуске'), + (ACTION_RETURNED_FROM_LEAVE, 'Вернулся из отпуска'), + (ACTION_ON_MATERNITY, 'В декрете'), + (ACTION_RETURNED_FROM_MATERNITY, 'Вернулся из декрета'), + (ACTION_TRANSFERRED, 'Переведен'), + (ACTION_REHIRED, 'Восстановлен'), + ] + + employee = models.ForeignKey( + Employee, related_name='actions', on_delete=models.CASCADE) + action = models.CharField(_('Кадровое событие'), + max_length=32, choices=ACTION_CHOICES) + date = models.DateTimeField(_('Дата действия')) + comment = models.TextField(_('Комментарий/причина'), blank=True) + extra = models.JSONField(_('Дополнительно'), blank=True, null=True) + + class Meta: + verbose_name = _('Кадровое событие') + verbose_name_plural = _('Кадровые события') + ordering = ['-date'] + + def __str__(self): + return f'{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}' diff --git a/backend/users/signals.py b/backend/users/signals.py new file mode 100644 index 00000000..ff7e3c84 --- /dev/null +++ b/backend/users/signals.py @@ -0,0 +1,44 @@ +# users/signals.py +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver +from django.utils import timezone + +from .models import Employee, EmployeeAction +from bots.models import BotSubscriber + + +@receiver(pre_save, sender=Employee) +def unbind_bot_subscriber_on_contact_change(sender, instance: Employee, **kwargs): + # Игнорируем новых пользователей + if instance.pk is None: + return + + old = sender.objects.get(pk=instance.pk) + try: + sub = old.bot_subscription + except BotSubscriber.DoesNotExist: + return + + # Если очистили или изменили telegram в профиле — сбросим telegram_id + if old.telegram and old.telegram != (instance.telegram or "").strip(): + sub.telegram_id = None + sub.save(update_fields=['telegram_id']) + + # Аналогично для whatsapp + if old.whatsapp and old.whatsapp != instance.whatsapp: + sub.whatsapp_id = None + sub.save(update_fields=['whatsapp_id']) + + +@receiver(post_save, sender=Employee) +def create_hired_action(sender, instance: Employee, created, **kwargs): + """ + При первом сохранении нового сотрудника создаём событие «Принят». + """ + if created: + EmployeeAction.objects.create( + employee=instance, + action=EmployeeAction.ACTION_HIRED, + date=instance.created_at or timezone.now(), + comment='Автоматически: принят при регистрации' + ) diff --git a/backend/users/tests.py b/backend/users/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 00000000..8698729a --- /dev/null +++ b/backend/users/views.py @@ -0,0 +1,41 @@ +import os +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages + +from .forms import ProfileUpdateForm +from bots.models import BotSubscriber +from dotenv import load_dotenv + +load_dotenv() + +USERNAME_TELEGRAM_BOT = os.getenv('USERNAME_TELEGRAM_BOT') + +@login_required +def profile(request): + bot_sub, _ = BotSubscriber.objects.get_or_create(user=request.user) + if request.method == 'POST': + form = ProfileUpdateForm(request.POST, request.FILES, instance=request.user) + if form.is_valid(): + form.save() + return redirect('profile') + else: + form = ProfileUpdateForm(instance=request.user) + + tg_link = f"https://t.me/{USERNAME_TELEGRAM_BOT}" + + return render(request, 'profile.html', { + 'form': form, + 'tg_link': tg_link, + 'bot_sub': bot_sub, + }) + +@login_required +def avatar_remove(request): + if request.method == 'POST': + user = request.user + if user.avatar: + user.avatar.delete(save=True) + messages.success(request, 'Аватар успешно удалён') + return redirect('profile') + return redirect('profile') diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..ac24449d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "EUSRR", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{}