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
This commit is contained in:
Игорь Надеин 2025-07-30 08:15:53 +03:00
commit 6fb2cdcea0
96 changed files with 3542 additions and 0 deletions

155
.gitignore vendored Normal file
View File

@ -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

7
backend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
# .dockerignore
venv
.git
db.sqlite3
.env
.idea
.vscode

7
backend/Dockerfile Normal file
View File

@ -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"]

28
backend/TEST.py Normal file
View File

@ -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)

0
backend/api/__init__.py Normal file
View File

3
backend/api/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
backend/api/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View File

3
backend/api/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
backend/api/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
backend/api/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
backend/bots/__init__.py Normal file
View File

3
backend/bots/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
backend/bots/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BotsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'bots'

40
backend/bots/filters.py Normal file
View File

@ -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)

View File

View File

@ -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()

View File

@ -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': 'Подписчики ботов',
},
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

32
backend/bots/models.py Normal file
View File

@ -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 "-"})'

View File

@ -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()

3
backend/bots/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

15
backend/bots/utils.py Normal file
View File

@ -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,
})

3
backend/bots/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View File

@ -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)

View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CalendarAppConfig(AppConfig):
name = 'calendar_app'
def ready(self):
import calendar_app.signals # noqa

View File

@ -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'],
},
),
]

View File

@ -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])

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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'),
]

View File

@ -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

View File

@ -0,0 +1 @@
default_app_config = 'documents.apps.DocumentsConfig'

116
backend/documents/admin.py Normal file
View File

@ -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('<a href="{}">{}</a>', 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('<span style="color:green;">{}</span>', _("Все ознакомились"))
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('<a href="{}">{}</a>',
url, u.get_full_name() or u))
return mark_safe('<br>'.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',)

10
backend/documents/apps.py Normal file
View File

@ -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

View File

@ -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')},
},
),
]

View File

View File

@ -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}'

View File

@ -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}")

View File

@ -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}")

177
backend/documents/tasks.py Normal file
View File

@ -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}"
# )

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/documents/urls.py Normal file
View File

@ -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/<int:pk>/', acknowledge_document, name='acknowledge'),
]

View File

@ -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')

View File

View File

@ -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()

View File

@ -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,
},
},
}

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1 @@
default_app_config = 'hikcentral.apps.HikcentralConfig'

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,10 @@
# hikcentral/apps.py
from django.apps import AppConfig
class HikcentralConfig(AppConfig):
name = 'hikcentral'
def ready(self):
import hikcentral.signals

View File

@ -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,
},
),
]

View File

@ -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}"

View File

@ -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()

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

22
backend/manage.py Normal file
View File

@ -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()

15
backend/requirements.txt Normal file
View File

@ -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

143
backend/templates/base.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}EUSRR{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- FullCalendar CSS -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
{% block extra_css %}{% endblock %}
<style>
nav.navbar.position-relative { position: relative; }
#calendarContainer {
position: absolute;
top: -9999px;
right: 1rem;
z-index: 1050;
width: 350px; max-width: 100%; height: 400px; max-height: 90vh;
overflow: hidden;
transition: top 0.2s ease-in-out;
}
#calendarContainer.visible { top: 100%; }
#mini-calendar { width: 100%; height: 100%; }
.fc .fc-toolbar-title { font-size: 1.2rem; font-weight: 500; }
.fc .fc-daygrid-day-number { font-size: 1rem; font-weight: 400; }
.fc .fc-daygrid-day-frame { padding: 0.5rem; }
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary position-relative">
<div class="container">
<a class="navbar-brand" href="/">EUSRR</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarContent" aria-controls="navbarContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto">
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'documents:document_list' %}">
<i class="bi-file-earmark-text"></i> Документы
</a>
</li>
{% endif %}
</ul>
<div class="d-flex align-items-center">
{% if user.is_authenticated %}
<span class="navbar-text me-3">Добро пожаловать, {{ user.get_short_name }}</span>
<!-- Кнопка переключения календаря -->
<button id="toggleCalendarBtn" class="btn btn-outline-light me-3" type="button">
<i class="bi-calendar3"></i>
</button>
<!-- Контейнер календаря -->
<div id="calendarContainer">
<div class="card shadow-sm">
<div class="card-body p-2">
<div id="mini-calendar"></div>
</div>
</div>
</div>
<form action="{% url 'logout' %}" method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-light">Выйти</button>
</form>
{% else %}
<a class="btn btn-outline-light me-2" href="{% url 'login' %}">Войти</a>
<a class="btn btn-light" href="{% url 'register' %}">Регистрация</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container mt-5">
{% block content %}{% endblock %}
</div>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- FullCalendar JS -->
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js"></script>
{% if user.is_authenticated %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('calendarContainer');
const toggleBtn = document.getElementById('toggleCalendarBtn');
// Инициализация FullCalendar
const calendar = new FullCalendar.Calendar(
document.getElementById('mini-calendar'),
{
initialView: 'dayGridMonth',
headerToolbar: { left: 'prev,next today', center: 'title', right: '' },
height: 400,
events: '/calendar/api/events/',
eventDidMount: info => {
if (info.event.extendedProps.recurrence === 'annual') {
info.el.style.borderColor = '#28a745';
}
}
}
);
calendar.render();
// Показ/скрытие календаря
toggleBtn.addEventListener('click', () => {
container.classList.toggle('visible');
if (container.classList.contains('visible')) {
calendar.updateSize();
}
});
// Закрытие по клику вне области
document.addEventListener('click', (e) => {
if (container.classList.contains('visible')
&& !container.contains(e.target)
&& e.target !== toggleBtn) {
container.classList.remove('visible');
}
});
});
</script>
{% endif %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,23 @@
<!-- templates/calendar/calendar.html -->
{% extends "base.html" %}
{% block content %}
<div id='calendar'></div>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet" />
<script>
document.addEventListener('DOMContentLoaded', function() {
let calendarEl = document.getElementById('calendar');
let calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
events: '/calendar/api/events/',
eventDidMount: function(info) {
// Можно пометить ежегодные события специальным цветом
if (info.event.extendedProps.recurrence === 'annual') {
info.el.style.borderColor = 'green';
}
}
});
calendar.render();
});
</script>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Документы{% endblock %}
{% block content %}
<h1>Документы</h1>
{% if documents %}
<ul class="list-group">
{% for doc in documents %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">
<a href="{{ doc.file.url }}" target="_blank">{{ doc.title }}</a>
</div>
{{ doc.description|default:"– без описания –" }}<br>
<small class="text-muted">Загружен: {{ doc.uploaded_at|date:"d.m.Y H:i" }}</small>
</div>
<div>
{% if doc.id in acked_ids %}
<span class="badge bg-success">Ознакомлен</span>
{% else %}
<a href="{% url 'documents:acknowledge' doc.pk %}" class="btn btn-sm btn-primary">
Ознакомиться
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{# пагинация, как было ранее #}
{% else %}
<p>Нет доступных документов.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,99 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block content %}
<div class="container py-5">
<div class="row g-5">
<!-- Блок с аватаром -->
<div class="col-md-4 text-center">
<div class="card shadow">
<div class="card-body">
{% if user.avatar and user.avatar.url %}
<img src="{{ user.avatar.url }}"
alt="Аватар {{ user.first_name }} {{ user.last_name }}"
class="img-fluid rounded-circle mb-3"
style="width: 200px; height: 200px; object-fit: cover;">
{% else %}
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center"
style="width: 200px; height: 200px; margin: 0 auto;">
<i class="bi bi-person-circle fs-1 text-muted"></i>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Форма редактирования -->
<div class="col-md-8">
<div class="card shadow">
<div class="card-body">
<h2 class="card-title mb-4">Редактирование профиля</h2>
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<div class="row g-3">
{% bootstrap_form form layout='horizontal' %}
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-2"></i>Сохранить изменения
</button>
<a href="{% url 'profile' %}" class="btn btn-outline-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Блок привязки Telegram -->
{% comment %}
Показываем, только если в контексте есть bot_sub и Telegram ещё не привязан
{% endcomment %}
{% if bot_sub and not bot_sub.telegram_id %}
<div class="row">
<div class="col-12">
<div class="alert alert-info mt-4">
<b>Для привязки Telegram:</b>
<a href="{{ tg_link }}" target="_blank" class="btn btn-success ms-2">
Привязать Telegram
</a>
</div>
</div>
</div>
{% endif %}
{% if bot_sub and not bot_sub.whatsapp_id %}
<div class="row">
<div class="col-12">
<div class="alert alert-info mt-4">
<b>Для привязки WhatsApp:</b>
<a href="{{ tg_link }}" target="_blank" class="btn btn-success ms-2">
Привязать WhatsApp
</a>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Предпросмотр аватара
const avatarInput = document.getElementById('avatarInput');
if (avatarInput) {
avatarInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const preview = document.querySelector('.rounded-circle');
if (preview.tagName === 'IMG') {
preview.src = URL.createObjectURL(file);
preview.onload = () => URL.revokeObjectURL(preview.src);
}
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,93 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Авторизация{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Вход в систему</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<!-- Общие ошибки формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4">
{% for error in form.non_field_errors %}
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<!-- Поле номера телефона -->
<div class="mb-4 form-floating">
<input type="tel"
name="username"
class="form-control {% if form.username.errors %}is-invalid{% endif %}"
id="id_username"
placeholder="+7 (999) 123-45-67"
value="{{ form.username.value|default:'' }}"
required
autofocus>
<label for="id_username">Номер телефона</label>
{% if form.username.errors %}
<div class="invalid-feedback">
{{ form.username.errors|striptags }}
</div>
{% endif %}
</div>
<!-- Поле пароля -->
<div class="mb-4 form-floating position-relative">
<input type="password"
name="password"
class="form-control {% if form.password.errors %}is-invalid{% endif %}"
id="id_password"
placeholder="Пароль"
required>
<label for="id_password">Пароль</label>
<i class="bi bi-eye-slash password-toggle position-absolute top-50 end-0 translate-middle-y me-3"
style="cursor: pointer; z-index: 5"></i>
{% if form.password.errors %}
<div class="invalid-feedback">
{{ form.password.errors|striptags }}
</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
</button>
<a href="{% url 'password_reset' %}" class="btn btn-link mt-2">Забыли пароль?</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Переключение видимости пароля
document.querySelectorAll('.password-toggle').forEach(icon => {
icon.addEventListener('click', function() {
const input = this.closest('.form-floating').querySelector('input');
input.type = input.type === 'password' ? 'text' : 'password';
this.classList.toggle('bi-eye-slash');
this.classList.toggle('bi-eye');
});
});
// Автофокус на поле логина
document.getElementById('id_username')?.focus();
});
</script>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% load bootstrap5 %}
{% block title %}Выход из системы{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-danger text-white">
<h3 class="mb-0">Выход из системы</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<div class="text-center mb-4">
<p class="lead">Вы уверены, что хотите выйти из системы?</p>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger btn-lg">
<i class="bi bi-power me-2"></i>Выйти
</button>
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary btn-lg">Остаться</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Смена пароля{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Смена пароля</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<div class="form-floating">
{{ form.old_password }}
<label for="{{ form.old_password.id_for_label }}">Текущий пароль</label>
</div>
</div>
<div class="mb-3">
<div class="form-floating">
{{ form.new_password1 }}
<label for="{{ form.new_password1.id_for_label }}">Новый пароль</label>
</div>
</div>
<div class="mb-3">
<div class="form-floating">
{{ form.new_password2 }}
<label for="{{ form.new_password2.id_for_label }}">Подтвердите новый пароль</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-key me-2"></i>Изменить пароль
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Пароль изменен{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-success text-white">
<h3 class="mb-0">Успешная смена пароля</h3>
</div>
<div class="card-body text-center">
<i class="bi bi-check-circle-fill text-success display-4 mb-4"></i>
<p class="lead">Ваш пароль был успешно изменен!</p>
<a href="{% url 'login' %}" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти с новым паролем
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Сброс пароля{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Сброс пароля</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<div class="form-floating">
{{ form.email }}
<label for="{{ form.email.id_for_label }}">Email</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-envelope-arrow-up me-2"></i>Отправить инструкции
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Пароль восстановлен{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-success text-white">
<h3 class="mb-0">Пароль успешно изменен</h3>
</div>
<div class="card-body text-center">
<i class="bi bi-shield-check text-success display-4 mb-4"></i>
<p class="lead">Ваш новый пароль был установлен. Теперь вы можете войти в систему.</p>
<a href="{% url 'login' %}" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти в систему
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Новый пароль{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Установка нового пароля</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<div class="form-floating">
{{ form.new_password1 }}
<label for="{{ form.new_password1.id_for_label }}">Новый пароль</label>
</div>
</div>
<div class="mb-3">
<div class="form-floating">
{{ form.new_password2 }}
<label for="{{ form.new_password2.id_for_label }}">Подтвердите пароль</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-key me-2"></i>Установить пароль
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Письмо отправлено{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-info text-white">
<h3 class="mb-0">Инструкции отправлены</h3>
</div>
<div class="card-body text-center">
<i class="bi bi-envelope-check-fill text-info display-4 mb-4"></i>
<p class="lead">
На ваш email отправлены инструкции по сбросу пароля.
Проверьте почту и следуйте указаниям в письме.
</p>
<a href="{% url 'login' %}" class="btn btn-primary">
<i class="bi bi-arrow-left me-2"></i>Вернуться к авторизации
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Регистрация сотрудника{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Регистрация нового сотрудника</h3>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<!-- Контакты -->
<div class="row g-3 mb-4">
<div class="col-md-6">
{% bootstrap_field form.email show_label=False placeholder="E-mail" %}
<small class="text-muted">Пример: ivanov@company.com</small>
</div>
<div class="col-md-6">
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
</div>
</div>
<!-- Мессенджеры -->
<div class="row g-3 mb-4 align-items-end">
<div class="col-md-6 position-relative">
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-whatsapp text-success"></i>
</span>
</div>
<div class="col-md-6 position-relative">
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-telegram text-primary"></i>
</span>
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4">
{% for error in form.non_field_errors %}
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
</div>
<!-- ФИО + дата рождения -->
<div class="row g-3 mb-4">
{% for field in form %}
{% if field.name == 'last_name' or field.name == 'first_name' or field.name == 'patronymic' %}
<div class="col-md-4">
{% bootstrap_field field show_label=False %}
</div>
{% endif %}
{% endfor %}
</div>
{% if form.birth_date %}
<div class="row g-3 mb-4">
<div class="col-md-4">
{% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
</div>
</div>
{% endif %}
<!-- Пароли -->
<div class="row g-3 mb-4">
<div class="col-md-6 position-relative">
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password1.auto_id }}"></i>
</span>
</div>
<div class="col-md-6 position-relative">
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password2.auto_id }}"></i>
</span>
</div>
</div>
<!-- Аватар -->
<div class="mb-4">
{% bootstrap_field form.avatar %}
<small class="text-muted">Рекомендуемый размер: 200x200 px</small>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-person-plus me-2"></i>Зарегистрироваться
</button>
<a href="{% url 'login' %}" class="btn btn-link">Уже есть аккаунт? Войти</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.password-toggle').forEach(icon => {
icon.addEventListener('click', function() {
const input = document.querySelector(this.getAttribute('data-target'));
if (input) {
input.type = input.type === 'password' ? 'text' : 'password';
this.classList.toggle('bi-eye');
this.classList.toggle('bi-eye-slash');
}
});
});
});
</script>
{% endblock %}

View File

43
backend/users/admin.py Normal file
View File

@ -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)

9
backend/users/apps.py Normal file
View File

@ -0,0 +1,9 @@
# users/apps.py
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals # noqa

View File

@ -0,0 +1,5 @@
GENDER_CHOICES = (
(1, 'Мужской'),
(2, 'Женский'),
(0, 'Не указан'),
)

84
backend/users/forms.py Normal file
View File

@ -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'
)

View File

@ -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()),
],
),
]

View File

@ -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='Пол'),
),
]

View File

@ -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'],
},
),
]

View File

@ -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()),
],
),
]

View File

142
backend/users/models.py Normal file
View File

@ -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}'

44
backend/users/signals.py Normal file
View File

@ -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='Автоматически: принят при регистрации'
)

3
backend/users/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

41
backend/users/views.py Normal file
View File

@ -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')

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "EUSRR",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

1
package.json Normal file
View File

@ -0,0 +1 @@
{}