403 lines
16 KiB
Python
Executable File
403 lines
16 KiB
Python
Executable File
from datetime import datetime
|
||
|
||
from asgiref.sync import sync_to_async
|
||
from django.core.validators import RegexValidator
|
||
from django.db import models
|
||
|
||
from hashlib import sha512, sha256
|
||
|
||
from django.db.models import Q
|
||
|
||
from .api_errors import *
|
||
import re
|
||
|
||
|
||
class City(models.Model):
|
||
code = models.CharField(primary_key=True, max_length=20, verbose_name="Код города", validators=[
|
||
RegexValidator(regex="^[0-9a-zA-Z_]*$"),
|
||
])
|
||
name = models.CharField(unique=True, max_length=50, verbose_name="Название города")
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.code})"
|
||
|
||
@staticmethod
|
||
async def to_choices_async():
|
||
return [item async for item in City.objects.order_by('name').values_list('code', 'name')]
|
||
|
||
@staticmethod
|
||
def to_choices():
|
||
return list(City.objects.order_by('name').values_list('code', 'name'))
|
||
|
||
@staticmethod
|
||
async def get_by_code(code):
|
||
if code is None:
|
||
return None
|
||
return await City.objects.aget(code=code)
|
||
|
||
|
||
class Account(models.Model):
|
||
surname = models.CharField(max_length=60, verbose_name="Фамилия", blank=True, default="")
|
||
name = models.CharField(max_length=60, verbose_name="Имя", blank=True, default="")
|
||
phone = models.CharField(unique=True, max_length=16, verbose_name="Телефон", validators=[
|
||
RegexValidator(regex="^\\+7[0-9]{10}$"),
|
||
])
|
||
email = models.EmailField(unique=True, verbose_name="Почта", blank=True, null=True, default=None)
|
||
|
||
password = models.CharField(max_length=64, blank=True, verbose_name="Хеш пароля")
|
||
|
||
# роль
|
||
ROLE_CUSTOMER, ROLE_EXECUTOR, ROLE_MODER, ROLE_ADMIN = range(4)
|
||
|
||
ROLE_CHOICES = [
|
||
(ROLE_CUSTOMER, 'Заказчик'),
|
||
(ROLE_EXECUTOR, 'Исполнитель'),
|
||
# TODO добавить модератора и админа в API
|
||
# (ROLE_MODER, 'Модератор'),
|
||
# (ROLE_ADMIN, 'Админ')
|
||
]
|
||
role = models.SmallIntegerField(choices=ROLE_CHOICES, verbose_name="Роль", editable=False)
|
||
|
||
about = models.CharField(max_length=1000, blank=True, default="", verbose_name="О себе")
|
||
|
||
city = models.ForeignKey(City, on_delete=models.SET_NULL, default=None, null=True, blank=True)
|
||
|
||
register_datetime = models.DateTimeField(default=datetime.now, editable=False)
|
||
|
||
@staticmethod
|
||
def create_user(phone: str, **kvargs):
|
||
return Account(phone=Account.normalize_phone(phone), **kvargs)
|
||
|
||
@staticmethod
|
||
def normalize_phone(phone):
|
||
# Гоша попросил запилить фичу, чтобы принимались номера:
|
||
# +79991112233
|
||
# 79991112233
|
||
# 9991112233
|
||
if re.match("^[0-9]{10}$", phone) is not None:
|
||
return f"+7{phone}"
|
||
elif re.match("^7[0-9]{10}$", phone) is not None:
|
||
return f"+{phone}"
|
||
else:
|
||
return phone
|
||
|
||
@staticmethod
|
||
async def get_by_natural_key(key):
|
||
try:
|
||
return await Account.objects.aget(phone=Account.normalize_phone(key))
|
||
except:
|
||
raise Exception(API_ERROR_NOT_FOUND, "user")
|
||
|
||
async def get_by_id(self, user_id: int):
|
||
u = Account.objects.filter(id=user_id).select_related('executoraccount', 'city')
|
||
if self.role == Account.ROLE_EXECUTOR or self.role == Account.ROLE_CUSTOMER:
|
||
u.filter(role__in=[Account.ROLE_EXECUTOR, Account.ROLE_CUSTOMER])
|
||
|
||
return await u.afirst()
|
||
|
||
def save(self, **kvargs):
|
||
if len(self.password) != 64:
|
||
self.password = sha256(self.password.encode('utf-8')).hexdigest()
|
||
|
||
if self.role != Account.ROLE_EXECUTOR and hasattr(self, 'executoraccount'):
|
||
self.executoraccount.delete()
|
||
|
||
super(Account, self).save(**kvargs)
|
||
|
||
def check_password(self, password):
|
||
return sha256(password.encode('utf-8')).hexdigest() == self.password
|
||
|
||
def __str__(self):
|
||
r = None if self.role is None else ExecutorAccount.EXECUTOR_TYPE_CHOICES[self.role][1]
|
||
return f"{self.name} {self.surname}: {self.phone} ({r})"
|
||
|
||
def is_completed(self):
|
||
return self.name != '' and self.surname != '' and self.city_id is not None
|
||
|
||
|
||
def _executor_additional_info_default():
|
||
return {}
|
||
|
||
|
||
class ExecutorAccount(models.Model):
|
||
account = models.OneToOneField(Account, on_delete=models.CASCADE, primary_key=True)
|
||
|
||
# тип исполнителя
|
||
EXECUTOR_TYPE_SELF_EMPLOYED, EXECUTOR_TYPE_LEGAL_ENTITY = range(2)
|
||
EXECUTOR_TYPE_CHOICES = [
|
||
(EXECUTOR_TYPE_SELF_EMPLOYED, "самозанятый"),
|
||
(EXECUTOR_TYPE_LEGAL_ENTITY, "юр. лицо"),
|
||
]
|
||
executor_type = models.SmallIntegerField(choices=EXECUTOR_TYPE_CHOICES, verbose_name="Тип исполнителя",
|
||
blank=True, null=True)
|
||
|
||
# ИНН
|
||
inn = models.CharField(max_length=12, null=True, blank=True, validators=[
|
||
RegexValidator(regex="^[0-9]{10}$|^[0-9]{12}$"),
|
||
], verbose_name="ИНН")
|
||
|
||
# дополнительная информация
|
||
additional_info = models.JSONField(default=_executor_additional_info_default, null=True, blank=True,
|
||
verbose_name="Дополнительные данные")
|
||
|
||
def __str__(self):
|
||
e_t = None if self.executor_type is None else ExecutorAccount.EXECUTOR_TYPE_CHOICES[self.executor_type][1]
|
||
return f"{self.account}: {e_t}"
|
||
|
||
|
||
class AccessToken(models.Model):
|
||
user = models.ForeignKey(Account, on_delete=models.CASCADE)
|
||
access_token = models.CharField(max_length=128, editable=False, unique=True)
|
||
creation_time = models.DateTimeField(default=datetime.now)
|
||
|
||
@staticmethod
|
||
async def create_token(user: Account):
|
||
token = AccessToken(user=user, access_token=AccessToken._generate_token(user))
|
||
print(f"created token {token.access_token[:16]}...")
|
||
await sync_to_async(token.save)()
|
||
return token
|
||
|
||
@staticmethod
|
||
async def auth(login: str, password: str):
|
||
try:
|
||
user = await Account.get_by_natural_key(login)
|
||
except Exception:
|
||
raise Exception(API_ERROR_INVALID_LOGIN)
|
||
|
||
if not user.check_password(password):
|
||
raise Exception(API_ERROR_INVALID_PASSWORD)
|
||
|
||
return await AccessToken.create_token(user)
|
||
|
||
@staticmethod
|
||
async def deauth(token: str):
|
||
t = await AccessToken.get_by_token(token)
|
||
await sync_to_async(t.delete)()
|
||
|
||
@staticmethod
|
||
async def get_user_by_token(token: str):
|
||
return (await AccessToken.get_by_token(token)).user
|
||
|
||
@staticmethod
|
||
async def get_by_token(token: str):
|
||
t = await AccessToken.objects.filter(access_token=token)\
|
||
.select_related('user', 'user__executoraccount', 'user__city').afirst()
|
||
if t is None:
|
||
raise Exception(API_ERROR_INVALID_TOKEN)
|
||
return t
|
||
|
||
def __str__(self):
|
||
return f"{self.user.name} {self.user.surname} ({self.user.phone}): {self.access_token[:8]}..."
|
||
|
||
@staticmethod
|
||
def _generate_token(user: Account):
|
||
return sha512(bytearray(user.phone + user.password + str(datetime.now()), 'utf-8')).hexdigest()
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.access_token is None or len(self.access_token) == 0:
|
||
self.access_token = AccessToken._generate_token(self.user)
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
async def list_sessions(self):
|
||
sessions = AccessToken.objects.filter(~Q(access_token=self.access_token), user=self.user)
|
||
return [item async for item in sessions]
|
||
|
||
async def delete_sessions_without_current(self):
|
||
await sync_to_async(AccessToken.objects.filter(~Q(access_token=self.access_token), user=self.user).delete)()
|
||
|
||
async def delete_session(self, session_id: int):
|
||
session = await AccessToken.objects.filter(~Q(access_token=self.access_token),
|
||
user=self.user, id=session_id).afirst()
|
||
if session is None:
|
||
raise Exception(API_ERROR_NOT_FOUND, "session")
|
||
|
||
await sync_to_async(session.delete)()
|
||
|
||
@staticmethod
|
||
async def delete_sessions(user: Account):
|
||
await sync_to_async(AccessToken.objects.filter(user=user).delete)()
|
||
|
||
|
||
class Order(models.Model):
|
||
# основные поля: название и описание
|
||
name = models.CharField(max_length=200, verbose_name="Название заказа")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
# площадь в квадратных метрах
|
||
square = models.DecimalField(max_digits=7, decimal_places=2, blank=False, verbose_name="Площадь в м²")
|
||
|
||
work_time = models.CharField(max_length=100, blank=True, verbose_name="Рабочее время")
|
||
|
||
# дальше вид дома, тип ремонта, тип квартиры, требуется дизайн проект, закуп материала, тип исполнителя
|
||
|
||
CHOICE_UNDEFINED = ''
|
||
|
||
# тип ремонта
|
||
TYPE_OF_RENOVATION_OVERHAUL = 'overhaul'
|
||
TYPE_OF_RENOVATION_PARTIAL = 'partial'
|
||
TYPE_OF_RENOVATION_REDECOR = 'redecor'
|
||
TYPE_OF_RENOVATION_PREMIUM = 'premium'
|
||
TYPE_OF_RENOVATION_DESIGN = 'design'
|
||
|
||
TYPE_OF_RENOVATION_CHOICES = [
|
||
(CHOICE_UNDEFINED, 'Не определено'),
|
||
(TYPE_OF_RENOVATION_OVERHAUL, 'Капитальный'),
|
||
(TYPE_OF_RENOVATION_PARTIAL, 'Частичный'),
|
||
(TYPE_OF_RENOVATION_REDECOR, 'Косметический'),
|
||
(TYPE_OF_RENOVATION_PREMIUM, 'Премиальный'),
|
||
(TYPE_OF_RENOVATION_DESIGN, 'Дизайнерский'),
|
||
]
|
||
type_of_renovation = models.CharField(max_length=10, choices=TYPE_OF_RENOVATION_CHOICES, default=CHOICE_UNDEFINED,
|
||
blank=True, verbose_name="Тип ремонта")
|
||
|
||
# тип дома
|
||
TYPE_OF_HOUSE_BLOCK = 'block'
|
||
TYPE_OF_HOUSE_BRICK = 'brick'
|
||
TYPE_OF_HOUSE_MONOLITH = 'monolith'
|
||
TYPE_OF_HOUSE_PANEL = 'panel'
|
||
|
||
TYPE_OF_HOUSE_CHOICES = [
|
||
(TYPE_OF_HOUSE_BLOCK, 'Блочный'),
|
||
(TYPE_OF_HOUSE_BRICK, 'Кирпичный'),
|
||
(TYPE_OF_HOUSE_MONOLITH, 'Монолит'),
|
||
(TYPE_OF_HOUSE_PANEL, 'Панельный'),
|
||
]
|
||
type_of_house = models.CharField(max_length=10, choices=TYPE_OF_HOUSE_CHOICES, blank=True, default=CHOICE_UNDEFINED,
|
||
verbose_name="Тип дома")
|
||
|
||
# тип квартиры
|
||
TYPE_OF_ROOM_PRIMARY = 'primary'
|
||
TYPE_OF_ROOM_SECONDARY = 'secondary'
|
||
|
||
TYPE_OF_ROOM_CHOICES = [
|
||
(TYPE_OF_ROOM_PRIMARY, 'Первичка'),
|
||
(TYPE_OF_ROOM_SECONDARY, 'Вторичка')
|
||
]
|
||
type_of_room = models.CharField(max_length=10, choices=TYPE_OF_ROOM_CHOICES, blank=True, default=CHOICE_UNDEFINED,
|
||
verbose_name="Тип квартиры")
|
||
|
||
# закуп материала
|
||
PURCHASE_OF_MATERIAL_EXECUTOR = 'executor'
|
||
PURCHASE_OF_MATERIAL_CUSTOMER = 'customer'
|
||
|
||
PURCHASE_OF_MATERIAL_CHOICES = [
|
||
(PURCHASE_OF_MATERIAL_EXECUTOR, 'Исполнитель'),
|
||
(PURCHASE_OF_MATERIAL_CUSTOMER, 'Заказчик')
|
||
]
|
||
purchase_of_material = models.CharField(max_length=10, choices=PURCHASE_OF_MATERIAL_CHOICES,
|
||
blank=True, default=CHOICE_UNDEFINED, verbose_name="Закуп материала")
|
||
|
||
# тип исполнителя
|
||
TYPE_OF_EXECUTOR_INDIVIDUAL = 'individual'
|
||
TYPE_OF_EXECUTOR_COMPANY = 'company'
|
||
|
||
TYPE_OF_EXECUTOR_CHOICES = [
|
||
(TYPE_OF_EXECUTOR_INDIVIDUAL, 'Самозанятый/бригада'),
|
||
(TYPE_OF_EXECUTOR_COMPANY, 'Компания')
|
||
]
|
||
type_of_executor = models.CharField(max_length=10, choices=TYPE_OF_EXECUTOR_CHOICES,
|
||
blank=True, default=CHOICE_UNDEFINED, verbose_name="Тип исполнителя")
|
||
|
||
# дальше отдельные параметры
|
||
is_with_warranty = models.BooleanField(default=True, verbose_name="С гарантией")
|
||
is_with_contract = models.BooleanField(default=False, verbose_name="Работа по договору")
|
||
is_require_design = models.BooleanField(default=False, verbose_name="Требуется дизайн проект")
|
||
is_with_trade = models.BooleanField(default=False, verbose_name="Возможен торг")
|
||
is_with_cleaning = models.BooleanField(default=False, verbose_name="С уборкой")
|
||
is_with_garbage_removal = models.BooleanField(default=False, verbose_name="С вывозом мусора")
|
||
|
||
# примерная цена
|
||
approximate_price = models.DecimalField(max_digits=12, decimal_places=2, blank=False, verbose_name="Цена")
|
||
|
||
date_start = models.DateField(null=True, blank=True, default=None, verbose_name="Дата начала")
|
||
date_end = models.DateField(null=True, blank=True, default=None, verbose_name="Дата окончания")
|
||
|
||
address_city = models.ForeignKey(City, on_delete=models.CASCADE, blank=False, related_name="address_city",
|
||
verbose_name="Город")
|
||
address_text = models.CharField(max_length=70, blank=True, verbose_name="Улица, дом")
|
||
|
||
owner = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="owner", verbose_name="Владелец")
|
||
|
||
email = models.EmailField(null=True, blank=True, verbose_name="Email")
|
||
phone = models.CharField(null=True, blank=True, max_length=16, verbose_name="Телефон", validators=[
|
||
RegexValidator(regex="^\\+7[0-9]{10}$")
|
||
])
|
||
|
||
create_time = models.DateTimeField(default=datetime.now, editable=False, verbose_name="Время создания")
|
||
|
||
moderated = models.BooleanField(default=True, verbose_name="Модерирован")
|
||
published = models.BooleanField(default=False, verbose_name="Опубликован")
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
@staticmethod
|
||
def get_all_for_user(user):
|
||
if user.is_staff:
|
||
return Order.objects.filter().order_by('create_time')
|
||
else:
|
||
return Order.objects.filter(published=True, moderated=True).order_by('create_time')
|
||
|
||
@staticmethod
|
||
def get_for_user_by_id(user, order_id):
|
||
q = Order.get_all_for_user(user).filter(id=order_id)
|
||
if len(q) == 0:
|
||
return None
|
||
else:
|
||
return q[0]
|
||
|
||
|
||
|
||
# def _upload_image_filename(instance, filename):
|
||
# name, ext = os.path.splitext(filename)
|
||
# fn = sha256((str(datetime.now()) + name).encode('utf-8')).hexdigest() + ext
|
||
# return "order-images/" + fn
|
||
#
|
||
#
|
||
# # TOFIX сделать удаление ненужных картинок из файловой системы
|
||
#
|
||
# class OrderImage(models.Model):
|
||
# MAX_IMAGES = 10
|
||
#
|
||
# order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order", verbose_name="Заказ")
|
||
# image = models.ImageField(upload_to=_upload_image_filename, verbose_name="Картинка")
|
||
#
|
||
# def __str__(self):
|
||
# return f"{self.id}: {self.order}"
|
||
#
|
||
# def save(self, *args, **kwargs):
|
||
# super(OrderImage, self).save(*args, **kwargs)
|
||
# img = Image.open(self.image.path)
|
||
#
|
||
# if img.width > 1920 or img.height > 1080:
|
||
# # нужно урезать фортку так, чтобы пропорции уменьшились пропорционально
|
||
# output_size = (1920, 1080)
|
||
# img.thumbnail(output_size)
|
||
# img.save(self.image.path)
|
||
#
|
||
#
|
||
# class OrderRespond(models.Model):
|
||
# create_time = models.DateTimeField(default=datetime.now, editable=False, verbose_name="Время отклика")
|
||
# user = models.ForeignKey(SiteUser, on_delete=models.CASCADE, related_name="respond_user", verbose_name="Пользователь")
|
||
# order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="respond_order", verbose_name="Заказ")
|
||
#
|
||
# class Meta:
|
||
# constraints = [
|
||
# models.UniqueConstraint(
|
||
# fields=['user', 'order'], name='unique_order_respond_user_order'
|
||
# )
|
||
# ]
|
||
#
|
||
# def __str__(self):
|
||
# return f"{self.order}: {self.user}"
|
||
#
|
||
# def save(self, *args, **kwargs):
|
||
# if Order.objects.get(id=self.order.id) == self.user.id:
|
||
# raise Exception("User can't respond to self order")
|
||
#
|
||
# self.full_clean()
|
||
#
|
||
# super().save(*args, **kwargs)
|
||
|