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:
commit
6fb2cdcea0
155
.gitignore
vendored
Normal file
155
.gitignore
vendored
Normal 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
7
backend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
# .dockerignore
|
||||
venv
|
||||
.git
|
||||
db.sqlite3
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal 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
28
backend/TEST.py
Normal 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
0
backend/api/__init__.py
Normal file
3
backend/api/admin.py
Normal file
3
backend/api/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/api/apps.py
Normal file
6
backend/api/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api'
|
||||
0
backend/api/migrations/__init__.py
Normal file
0
backend/api/migrations/__init__.py
Normal file
3
backend/api/models.py
Normal file
3
backend/api/models.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
backend/api/tests.py
Normal file
3
backend/api/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/api/views.py
Normal file
3
backend/api/views.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
backend/bots/__init__.py
Normal file
0
backend/bots/__init__.py
Normal file
3
backend/bots/admin.py
Normal file
3
backend/bots/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/bots/apps.py
Normal file
6
backend/bots/apps.py
Normal 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
40
backend/bots/filters.py
Normal 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)
|
||||
0
backend/bots/management/__init__.py
Normal file
0
backend/bots/management/__init__.py
Normal file
0
backend/bots/management/commands/__init__.py
Normal file
0
backend/bots/management/commands/__init__.py
Normal file
9
backend/bots/management/commands/start_telegram_bot.py
Normal file
9
backend/bots/management/commands/start_telegram_bot.py
Normal 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()
|
||||
34
backend/bots/migrations/0001_initial.py
Normal file
34
backend/bots/migrations/0001_initial.py
Normal 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': 'Подписчики ботов',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
0
backend/bots/migrations/__init__.py
Normal file
0
backend/bots/migrations/__init__.py
Normal file
32
backend/bots/models.py
Normal file
32
backend/bots/models.py
Normal 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 "-"})'
|
||||
441
backend/bots/services/telegram.py
Normal file
441
backend/bots/services/telegram.py
Normal 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
3
backend/bots/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
15
backend/bots/utils.py
Normal file
15
backend/bots/utils.py
Normal 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
3
backend/bots/views.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
backend/calendar_app/__init__.py
Normal file
0
backend/calendar_app/__init__.py
Normal file
26
backend/calendar_app/admin.py
Normal file
26
backend/calendar_app/admin.py
Normal 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)
|
||||
8
backend/calendar_app/apps.py
Normal file
8
backend/calendar_app/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CalendarAppConfig(AppConfig):
|
||||
name = 'calendar_app'
|
||||
|
||||
def ready(self):
|
||||
import calendar_app.signals # noqa
|
||||
33
backend/calendar_app/migrations/0001_initial.py
Normal file
33
backend/calendar_app/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/calendar_app/migrations/__init__.py
Normal file
0
backend/calendar_app/migrations/__init__.py
Normal file
41
backend/calendar_app/models.py
Normal file
41
backend/calendar_app/models.py
Normal 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])
|
||||
28
backend/calendar_app/serializers.py
Normal file
28
backend/calendar_app/serializers.py
Normal 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()
|
||||
59
backend/calendar_app/signals.py
Normal file
59
backend/calendar_app/signals.py
Normal 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()
|
||||
3
backend/calendar_app/tests.py
Normal file
3
backend/calendar_app/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
backend/calendar_app/urls.py
Normal file
9
backend/calendar_app/urls.py
Normal 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'),
|
||||
]
|
||||
56
backend/calendar_app/views.py
Normal file
56
backend/calendar_app/views.py
Normal 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
|
||||
1
backend/documents/__init__.py
Normal file
1
backend/documents/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'documents.apps.DocumentsConfig'
|
||||
116
backend/documents/admin.py
Normal file
116
backend/documents/admin.py
Normal 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
10
backend/documents/apps.py
Normal 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
|
||||
50
backend/documents/migrations/0001_initial.py
Normal file
50
backend/documents/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/documents/migrations/__init__.py
Normal file
0
backend/documents/migrations/__init__.py
Normal file
76
backend/documents/models.py
Normal file
76
backend/documents/models.py
Normal 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}'
|
||||
98
backend/documents/notification.py
Normal file
98
backend/documents/notification.py
Normal 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}")
|
||||
41
backend/documents/signals.py
Normal file
41
backend/documents/signals.py
Normal 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
177
backend/documents/tasks.py
Normal 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}"
|
||||
# )
|
||||
3
backend/documents/tests.py
Normal file
3
backend/documents/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
backend/documents/urls.py
Normal file
10
backend/documents/urls.py
Normal 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'),
|
||||
]
|
||||
39
backend/documents/views.py
Normal file
39
backend/documents/views.py
Normal 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')
|
||||
0
backend/eusrr_backend/__init__.py
Normal file
0
backend/eusrr_backend/__init__.py
Normal file
16
backend/eusrr_backend/asgi.py
Normal file
16
backend/eusrr_backend/asgi.py
Normal 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()
|
||||
184
backend/eusrr_backend/settings.py
Normal file
184
backend/eusrr_backend/settings.py
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
22
backend/eusrr_backend/urls.py
Normal file
22
backend/eusrr_backend/urls.py
Normal 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)
|
||||
16
backend/eusrr_backend/wsgi.py
Normal file
16
backend/eusrr_backend/wsgi.py
Normal 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()
|
||||
1
backend/hikcentral/__init__.py
Normal file
1
backend/hikcentral/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'hikcentral.apps.HikcentralConfig'
|
||||
3
backend/hikcentral/admin.py
Normal file
3
backend/hikcentral/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
10
backend/hikcentral/apps.py
Normal file
10
backend/hikcentral/apps.py
Normal file
@ -0,0 +1,10 @@
|
||||
# hikcentral/apps.py
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HikcentralConfig(AppConfig):
|
||||
name = 'hikcentral'
|
||||
|
||||
def ready(self):
|
||||
import hikcentral.signals
|
||||
65
backend/hikcentral/migrations/0001_initial.py
Normal file
65
backend/hikcentral/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/hikcentral/migrations/__init__.py
Normal file
0
backend/hikcentral/migrations/__init__.py
Normal file
68
backend/hikcentral/models.py
Normal file
68
backend/hikcentral/models.py
Normal 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}"
|
||||
77
backend/hikcentral/signals.py
Normal file
77
backend/hikcentral/signals.py
Normal 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()
|
||||
3
backend/hikcentral/tests.py
Normal file
3
backend/hikcentral/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
50
backend/hikcentral/utils.py
Normal file
50
backend/hikcentral/utils.py
Normal 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
|
||||
3
backend/hikcentral/views.py
Normal file
3
backend/hikcentral/views.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal 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
15
backend/requirements.txt
Normal 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
143
backend/templates/base.html
Normal 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>
|
||||
23
backend/templates/calendar/calendar.html
Normal file
23
backend/templates/calendar/calendar.html
Normal 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 %}
|
||||
33
backend/templates/documents/document_list.html
Normal file
33
backend/templates/documents/document_list.html
Normal 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 %}
|
||||
99
backend/templates/profile.html
Normal file
99
backend/templates/profile.html
Normal 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 %}
|
||||
93
backend/templates/registration/login.html
Normal file
93
backend/templates/registration/login.html
Normal 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 %}
|
||||
33
backend/templates/registration/logout.html
Normal file
33
backend/templates/registration/logout.html
Normal 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 %}
|
||||
42
backend/templates/registration/password_change.html
Normal file
42
backend/templates/registration/password_change.html
Normal 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 %}
|
||||
23
backend/templates/registration/password_change_done.html
Normal file
23
backend/templates/registration/password_change_done.html
Normal 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 %}
|
||||
30
backend/templates/registration/password_reset.html
Normal file
30
backend/templates/registration/password_reset.html
Normal 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 %}
|
||||
23
backend/templates/registration/password_reset_complete.html
Normal file
23
backend/templates/registration/password_reset_complete.html
Normal 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 %}
|
||||
36
backend/templates/registration/password_reset_confirm.html
Normal file
36
backend/templates/registration/password_reset_confirm.html
Normal 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 %}
|
||||
26
backend/templates/registration/password_reset_done.html
Normal file
26
backend/templates/registration/password_reset_done.html
Normal 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 %}
|
||||
121
backend/templates/registration/register.html
Normal file
121
backend/templates/registration/register.html
Normal 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 %}
|
||||
0
backend/users/__init__.py
Normal file
0
backend/users/__init__.py
Normal file
43
backend/users/admin.py
Normal file
43
backend/users/admin.py
Normal 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
9
backend/users/apps.py
Normal 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
|
||||
5
backend/users/constants.py
Normal file
5
backend/users/constants.py
Normal file
@ -0,0 +1,5 @@
|
||||
GENDER_CHOICES = (
|
||||
(1, 'Мужской'),
|
||||
(2, 'Женский'),
|
||||
(0, 'Не указан'),
|
||||
)
|
||||
84
backend/users/forms.py
Normal file
84
backend/users/forms.py
Normal 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'
|
||||
)
|
||||
49
backend/users/migrations/0001_initial.py
Normal file
49
backend/users/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
backend/users/migrations/0002_employee_gender.py
Normal file
18
backend/users/migrations/0002_employee_gender.py
Normal 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='Пол'),
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/users/migrations/0004_alter_employee_managers.py
Normal file
20
backend/users/migrations/0004_alter_employee_managers.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
142
backend/users/models.py
Normal file
142
backend/users/models.py
Normal 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
44
backend/users/signals.py
Normal 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
3
backend/users/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
41
backend/users/views.py
Normal file
41
backend/users/views.py
Normal 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
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "EUSRR",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
1
package.json
Normal file
1
package.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
Loading…
x
Reference in New Issue
Block a user