commit b02b9e18110d363098982ab4e6ce423d287fc453 Author: VladislavOstapov Date: Mon Mar 6 20:27:57 2023 +0300 initial commit diff --git a/api/__init__.py b/api/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/api/__pycache__/__init__.cpython-311.pyc b/api/__pycache__/__init__.cpython-311.pyc new file mode 100755 index 0000000..8eca208 Binary files /dev/null and b/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/api/__pycache__/admin.cpython-311.pyc b/api/__pycache__/admin.cpython-311.pyc new file mode 100755 index 0000000..5c64fbf Binary files /dev/null and b/api/__pycache__/admin.cpython-311.pyc differ diff --git a/api/__pycache__/api_errors.cpython-311.pyc b/api/__pycache__/api_errors.cpython-311.pyc new file mode 100755 index 0000000..69b6104 Binary files /dev/null and b/api/__pycache__/api_errors.cpython-311.pyc differ diff --git a/api/__pycache__/api_methods.cpython-311.pyc b/api/__pycache__/api_methods.cpython-311.pyc new file mode 100644 index 0000000..9689d3a Binary files /dev/null and b/api/__pycache__/api_methods.cpython-311.pyc differ diff --git a/api/__pycache__/api_params.cpython-311.pyc b/api/__pycache__/api_params.cpython-311.pyc new file mode 100755 index 0000000..8be9f8d Binary files /dev/null and b/api/__pycache__/api_params.cpython-311.pyc differ diff --git a/api/__pycache__/api_phone_verificator.cpython-311.pyc b/api/__pycache__/api_phone_verificator.cpython-311.pyc new file mode 100644 index 0000000..9a42854 Binary files /dev/null and b/api/__pycache__/api_phone_verificator.cpython-311.pyc differ diff --git a/api/__pycache__/api_utils.cpython-311.pyc b/api/__pycache__/api_utils.cpython-311.pyc new file mode 100755 index 0000000..ccdd410 Binary files /dev/null and b/api/__pycache__/api_utils.cpython-311.pyc differ diff --git a/api/__pycache__/apps.cpython-311.pyc b/api/__pycache__/apps.cpython-311.pyc new file mode 100755 index 0000000..93451bb Binary files /dev/null and b/api/__pycache__/apps.cpython-311.pyc differ diff --git a/api/__pycache__/models.cpython-311.pyc b/api/__pycache__/models.cpython-311.pyc new file mode 100755 index 0000000..1d4e314 Binary files /dev/null and b/api/__pycache__/models.cpython-311.pyc differ diff --git a/api/__pycache__/urls.cpython-311.pyc b/api/__pycache__/urls.cpython-311.pyc new file mode 100755 index 0000000..feb2de1 Binary files /dev/null and b/api/__pycache__/urls.cpython-311.pyc differ diff --git a/api/__pycache__/views.cpython-311.pyc b/api/__pycache__/views.cpython-311.pyc new file mode 100755 index 0000000..909d11c Binary files /dev/null and b/api/__pycache__/views.cpython-311.pyc differ diff --git a/api/admin.py b/api/admin.py new file mode 100755 index 0000000..b488e3e --- /dev/null +++ b/api/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin +from .models import * + + +@admin.register(Account) +class AccountAdmin(admin.ModelAdmin): + # fields = ['name', 'surname', 'phone', 'email', 'register_datetime'] + list_display = ['id', 'name', 'surname', 'role', 'phone', 'register_datetime'] + readonly_fields = ['id', 'register_datetime'] + + +@admin.register(ExecutorAccount) +class ExecutorAccountAdmin(admin.ModelAdmin): + # fields = ['name', 'surname', 'phone', 'email', 'register_datetime'] + # list_display = ['name', 'surname', 'phone', 'register_datetime'] + # readonly_fields = ['register_datetime'] + pass + + +@admin.register(AccessToken) +class AccessTokenAdmin(admin.ModelAdmin): + readonly_fields = ['id', 'access_token'] + list_display = ['id', 'user', 'creation_time', 'small_access_token'] + fields = ['user', 'creation_time', 'access_token'] + ordering = ['-creation_time'] + + def small_access_token(self, obj): + return f"{obj.access_token[:8]}..." + + +@admin.register(City) +class CityAdmin(admin.ModelAdmin): + list_display = ['code', 'name'] + ordering = ['name'] + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ['owner', 'phone', 'name', 'create_time', 'moderated', 'published'] + readonly_fields = ['create_time'] +# +# +# @admin.register(OrderImage) +# class OrderImageAdmin(admin.ModelAdmin): +# pass +# +# +# @admin.register(OrderRespond) +# class OrderRespondAdmin(admin.ModelAdmin): +# pass diff --git a/api/api_errors.py b/api/api_errors.py new file mode 100755 index 0000000..5bfc6da --- /dev/null +++ b/api/api_errors.py @@ -0,0 +1,97 @@ +import traceback +from .api_phone_verificator import PhoneVerificationService +from arka.settings import PHONE_VERIFICATION_RESEND_TIME_SECS + +# как создавать ошибку +# raise Exception(API_ERROR_XXX, ) + +API_OK_OBJ = {"status": "success"} + +API_ERROR_INTERNAL_ERROR = (100, 'internal error') + +API_ERROR_MULTIPLY_ERRORS = (101, 'multiply errors') +API_ERROR_NOT_FOUND = (102, 'object not found') +API_ERROR_ACCESS_DENIED = (103, 'you cannot call this method: permission denied') +API_ERROR_NEED_COMPLETED_ACCOUNT = (104, 'need completed account') +API_ERROR_NOT_ALLOWED = (105, 'operation not allowed') + +API_ERROR_METHOD_NOT_FOUND = (200, 'method not found') +API_ERROR_MISSING_ARGUMENT = (201, 'missing argument') +API_ERROR_UNKNOWN_ARGUMENT = (202, 'unknown argument') +API_ERROR_INVALID_ARGUMENT_TYPE = (203, 'invalid argument type') +API_ERROR_INVALID_ARGUMENT_VALUE = (204, 'invalid argument value') + +API_ERROR_TOKEN_CREATION = (500, 'token creation error') + +API_ERROR_INVALID_LOGIN = (501, 'invalid login') +API_ERROR_INVALID_PASSWORD = (502, 'invalid password') +API_ERROR_INVALID_TOKEN = (503, 'invalid token') + +# времненное решение, позже нужно будет заменить на конкретные ошибки +API_ERROR_USER_REGISTER = (510, 'user registration error') +API_ERROR_NEED_VERIFY = (511, 'need verification code') +API_ERROR_USER_MODIFY = (512, 'user modification error') +API_ERROR_OBJECT_VALIDATION = (513, 'object validation error') + + +API_ERROR_VERIFY_INVALID_CODE = (520, 'invalid code') +API_ERROR_VERIFY_MAX_ATTEMPTS = (521, 'max attempts') +API_ERROR_CURRENTLY_VERIFIED = (522, 'currently phone is verified') +API_ERROR_VERIFY_FAILED = (523, 'cannot be verified') +API_ERROR_VERIFY_NOT_READY = (524, 'verification service not ready. call this method later') +API_ERROR_VERIFY_NOT_FOUND = (525, 'verification service did not send code. call this method without \'code\'') +API_ERROR_VERIFY_RESEND_LIMIT = (526, f'resend verification limit ' + f'(one verify for {PHONE_VERIFICATION_RESEND_TIME_SECS} secs)') +API_ERROR_VERIFY_UNKNOWN = (527, 'unknown verification error') + +API_ERROR_VERIFICATION = { + PhoneVerificationService.CHECK_PHONE_INVALID_CODE: API_ERROR_VERIFY_INVALID_CODE, + PhoneVerificationService.CHECK_PHONE_MAX_ATTEMPTS: API_ERROR_VERIFY_MAX_ATTEMPTS, + PhoneVerificationService.CHECK_PHONE_FAILED: API_ERROR_VERIFY_FAILED, + PhoneVerificationService.CHECK_PHONE_NOT_READY: API_ERROR_VERIFY_NOT_READY, + PhoneVerificationService.CHECK_PHONE_NOT_FOUND: API_ERROR_VERIFY_NOT_FOUND, + PhoneVerificationService.CHECK_PHONE_RESEND_LIMIT: API_ERROR_VERIFY_RESEND_LIMIT, +} + + +def __make_error(ex: Exception): + if type(ex.args[0]) != tuple: + raise ex + + error = { + "code": ex.args[0][0], + "message": ex.args[0][1] + } + + if len(ex.args) >= 2: + error["related"] = ex.args[1] + + return error + + +def make_error_object(ex: Exception | list): + try: + data = { + "status": "error" + } + if type(ex) == list: + data["error"] = { + "code": API_ERROR_MULTIPLY_ERRORS[0], + "message": API_ERROR_MULTIPLY_ERRORS[1], + } + data["related"] = [__make_error(e) for e in ex] + else: + data["error"] = __make_error(ex) + + return data + except BaseException as err: + traceback.print_exc() + + return { + "status": "error", + "error": { + "code": API_ERROR_INTERNAL_ERROR[0], + "message": API_ERROR_INTERNAL_ERROR[1], + "related": f"Exception {type(err)}: {str(err)}" + } + } diff --git a/api/api_methods.py b/api/api_methods.py new file mode 100755 index 0000000..fc3dbfd --- /dev/null +++ b/api/api_methods.py @@ -0,0 +1,653 @@ +from django.core.exceptions import ValidationError +from .api_utils import * +from .api_params import * +from .models import * +import time + + +def _make_model_validation_errors(validation_error: ValidationError, api_err=API_ERROR_OBJECT_VALIDATION): + errors = {} + for field_name in validation_error.error_dict: + err_list = validation_error.error_dict[field_name] + print(err_list) + obj = [] + for err in err_list: + obj.append(str(err.code)) + errors[field_name] = obj + return make_error_object(Exception(api_err, errors)) + + +class ApiAccount: + @staticmethod + def __check_phone_code(phone, code): + if code is None: + res, err_code = PhoneVerificationService.send_verify(phone) + if not res: + if err_code in API_ERROR_VERIFICATION: + raise Exception(API_ERROR_VERIFICATION[err_code]) + else: + raise Exception(API_ERROR_VERIFY_UNKNOWN) + + raise Exception(API_ERROR_NEED_VERIFY) + else: + res, err_code = PhoneVerificationService.check_code(phone, code) + if not res: + if err_code in API_ERROR_VERIFICATION: + raise Exception(API_ERROR_VERIFICATION[err_code]) + else: + raise Exception(API_ERROR_VERIFY_UNKNOWN) + + return True + + @staticmethod + def __make_user_json(user: Account): + obj = { + "id": user.id, + "name": user.name, + "surname": user.surname, + "phone": user.phone, + "email": user.email, + "about": user.about, + "city": {"code": user.city.code, "name": user.city.name} if user.city is not None else None, + "register_datetime": int(time.mktime(user.register_datetime.timetuple())), + "role": user.role, + } + if user.role == Account.ROLE_EXECUTOR: + obj |= { + "executor_type": user.executoraccount.executor_type, + "executor_inn": user.executoraccount.inn, + "executor_info": user.executoraccount.additional_info + } + + return obj + + @staticmethod + @api_method("account.register", + doc="Регистрация нового пользователя", + params=[ + ApiParamEnum(name="role", choices=Account.ROLE_CHOICES, description="Роль пользователя: {choices}"), + ApiParamPhone(), + ApiParamVerifyCode(), + ApiParamPassword(required=False, + description=f"Пароль пользователя, требуется для завершения регистрации. " + f"Если код верификации будет принят, но пароль не передан" + f"метод вернет ошибку {API_ERROR_MISSING_ARGUMENT}"), + ], returns="Аналогично методу account.auth в случае успеха") + async def register(role, phone, password, code): + user = Account.create_user( + phone=phone, + password=(password if password is not None else ""), + role=role + ) + + try: + await sync_to_async(user.full_clean)() + except ValidationError as validation_error: + # traceback.print_exc() + return _make_model_validation_errors(validation_error, API_ERROR_USER_REGISTER) + + if ApiAccount.__check_phone_code(user.phone, code): + if password is None: + raise Exception(API_ERROR_MISSING_ARGUMENT, "password") + + user = await Account.objects.acreate(phone=phone, password=password, role=role) + if role == Account.ROLE_EXECUTOR: + await ExecutorAccount.objects.acreate(account=user) + + # удаляем код, типа завершение транзакции + PhoneVerificationService.check_code(phone, code, auto_pop=True) + + try: + token = await AccessToken.create_token(user) + return api_make_response({"access_token": token.access_token}) + except Exception as ex: + # если вдруг токен нельзя создать + user.delete() + raise ex + + @staticmethod + @api_method("account.auth", + doc="Аутентификация пользователя", + params=[ + ApiParamPhone(name="login", description="Логин пользователя"), + ApiParamPassword(), + ], + returns="В случае правильных логина и пароля access_token. " + "В противном случае объект ошибки.") + async def auth(login, password): + return api_make_response({"access_token": (await AccessToken.auth(login, password)).access_token}) + + @staticmethod + @api_method("account.deauth", + doc="Удаление токена, дальшейшие вызовы API с этим токеном вернут ошибку невалидного токена", + params=[ + ApiParamAccessToken(), + ], returns="В случае успеха стандартный код успеха") + async def deauth(access_token): + await AccessToken.deauth(access_token.access_token) + return api_make_response({}) + + @staticmethod + @api_method("account.delete", + doc='Удаление аккаунта пользователя БЕЗ ВОЗМОЖНОСТИ ВОССТАНОВЛЕНИЯ. ' + 'Так же будут удалены ВСЕ связанные с аккаунтом данные.', + params=[ApiParamAccessToken()], + returns="Стандартный ответ успеха, в случае успеха") + async def delete(access_token): + user = access_token.user + await sync_to_async(user.delete)() + return api_make_response({}) + + @staticmethod + @api_method("account.get", + doc="Получение информации о пользователе", + params=[ + ApiParamAccessToken(), + ApiParamInt(name="user_id", required=False, value_min=0, + description="ID пользователя, аккаунт которого нужно вернуть. " + "Если не указывать, вернет аккаунт владельца.") + ], + returns="Поля пользователя (name, surname, email, phone и прочие).") + async def get(access_token, user_id): + if user_id is None: + user = access_token.user + else: + user = await access_token.user.get_by_id(user_id) + if user is None: + return make_error_object(Exception(API_ERROR_NOT_FOUND, {"user": user_id})) + + return api_make_response(ApiAccount.__make_user_json(user)) + + @staticmethod + @api_method("account.edit", + doc="Редактирование основной информации о пользователе. " + "Будут изменены только те данные, которые будут переданы в запросе, остальные не будут изменены.", + params=[ + ApiParamAccessToken(), + ApiParamStr(name="name", description="Имя пользователя", + min_length=2, max_length=60, required=False), + ApiParamStr(name="surname", description="Фамилия пользователя", + min_length=2, max_length=60, required=False), + ApiParamStr(name="about", description="Текст о себе. максимум - 1000 символов", + max_length=1000, required=False), + ApiParamEnum(name="executor_type", choices=ExecutorAccount.EXECUTOR_TYPE_CHOICES, required=False, + description="Тип исполнителя (только для роли исполнитель): {choices}"), + ApiParamStr(name="executor_inn", required=False, regex="^(\\d{12}|\\d{10})$", + description="ИНН исполнителя (только для роли исполнитель): " + "12 цифр если исполнитель - физ.лицо и 10 цифр если это юр. лицо"), + ApiParamEnum(name="city", description="Город, в котором находится ползователь: {choices}", + required=False, choices=City.to_choices) + ], + returns="Вернет основную информацию о пользователе, иначе ошибки") + async def edit(access_token, name, surname, about, executor_type, executor_inn, city): + user = access_token.user + executor_need_save, need_save = False, False + + if name is not None: + user.name = name + need_save = True + + if surname is not None: + user.surname = surname + need_save = True + + if about is not None: + user.about = about + need_save = True + + if city is not None: + user.city = city + need_save = True + + if user.role == Account.ROLE_EXECUTOR: + print("Executor account detected") + if executor_type is not None: + print("Executor account type detected") + user.executoraccount.executor_type = executor_type + executor_need_save = True + + if executor_inn is not None: + print("Executor account inn detected") + t = executor_type + if t is None: + t = user.executoraccount.executor_type + + if t is None: + raise Exception(API_ERROR_MISSING_ARGUMENT, "executor_type") + + if t == ExecutorAccount.EXECUTOR_TYPE_SELF_EMPLOYED: + if re.match("^\\d{12}$", executor_inn) is None: + raise Exception(API_ERROR_INVALID_ARGUMENT_VALUE, "executor_inn must be defined as 12 digits") + + if t == ExecutorAccount.EXECUTOR_TYPE_LEGAL_ENTITY: + if re.match("^\\d{10}$", executor_inn) is None: + raise Exception(API_ERROR_INVALID_ARGUMENT_VALUE, "executor_inn must be defined as 10 digits") + + user.executoraccount.inn = executor_inn + executor_need_save = True + + if need_save: + try: + await sync_to_async(user.full_clean)() + except ValidationError as ve: + return _make_model_validation_errors(ve, API_ERROR_USER_MODIFY) + + await sync_to_async(user.save)() + + if executor_need_save: + try: + await sync_to_async(user.executoraccount.full_clean)() + except ValidationError as ve: + return _make_model_validation_errors(ve, API_ERROR_USER_MODIFY) + + await sync_to_async(user.executoraccount.save)() + + return api_make_response(ApiAccount.__make_user_json(user)) + + @staticmethod + @api_method("account.changePhone", + doc="Смена пароля. Для подтверждения требуется старый пароль.", + params=[ + ApiParamAccessToken(), + ApiParamPassword(description="Пароль пользователя, нужен для подтверждения владельца аккаунта"), + ApiParamPhone(description="Новый телефон пользователя"), + ApiParamVerifyCode(description="Код подтверждения операции, придет на новый телефон") + ], + returns="Вернет стандартный объект успеха") + async def change_phone(access_token, password, phone, code): + user = access_token.user + + if not user.check_password(password): + raise Exception(API_ERROR_INVALID_PASSWORD) + + # чекаем телефон + user.phone = phone + try: + await sync_to_async(user.full_clean)() + except ValidationError as ve: + return _make_model_validation_errors(ve, API_ERROR_USER_MODIFY) + + if ApiAccount.__check_phone_code(user.phone, code): + await Account.objects.filter(id=user.id).aupdate(phone=phone) + PhoneVerificationService.check_code(user.phone, code, auto_pop=True) + + return api_make_response({}) + + @staticmethod + @api_method("account.resetPassword", + doc="Смена пароля. Для подтверждения действия требуется код с телефона. Так же в случае успешного " + "восстановления удалит все существующие сессии пользователя.", + params=[ + ApiParamPhone(description="Телефон, для которого нужно сделать восстановление"), + ApiParamPassword(name="new_password", required=False, + description="Новый пароль, нужен после того, как будет отправлен код (" + "можно передать раньше, тогда он будет проверен сразу)"), + ApiParamVerifyCode(description="Код подтверждения операции, придет на телефон") + ], + returns="Вернет стандартный объект успеха") + async def reset_password(new_password, phone, code): + user = await Account.get_by_natural_key(phone) + if new_password is not None: + user.password = new_password + try: + await sync_to_async(user.full_clean)() + except: + traceback.print_exc() + raise Exception(API_ERROR_INVALID_PASSWORD) + + if ApiAccount.__check_phone_code(phone, code): + if code is not None and new_password is None: + raise Exception(API_ERROR_MISSING_ARGUMENT, "new_password") + + await sync_to_async(user.save)() + await AccessToken.delete_sessions(user) + PhoneVerificationService.check_code(phone, code, auto_pop=True) + + return api_make_response({}) + + +class ApiSecurity: + @staticmethod + @api_method("security.listSessions", + doc="Получение сиписка сессий (кроме текущей)", + params=[ + ApiParamAccessToken(), + ApiParamPassword(description="Пароль пользователя, нужен для подтверждения личности") + ], + returns="Вернет sessions: [{id: int, name: str, created: unix_timestamp}]") + async def list_sessions(access_token, password): + sessions = await access_token.list_sessions() + + if not access_token.user.check_password(password): + raise Exception(API_ERROR_INVALID_PASSWORD) + + return api_make_response({ + "current": { + "id": access_token.id, + "created": int(time.mktime(access_token.creation_time.timetuple())), + }, + "sessions": [ + { + "id": s.id, + "name": f"{s.access_token[:8]}...", + "created": int(time.mktime(s.creation_time.timetuple())), + } for s in sessions + ] + }) + + @staticmethod + @api_method("security.removeOtherSessions", + doc="Получение сиписка сессий (кроме текущей)", + params=[ + ApiParamAccessToken(), + ApiParamPassword(description="Пароль пользователя, нужен для подтверждения личности") + ], + returns="Вернет sessions: [{id: int, name: str, created: unix_timestamp}]") + async def remove_other_sessions(access_token, password): + if not access_token.user.check_password(password): + raise Exception(API_ERROR_INVALID_PASSWORD) + + sessions = await access_token.list_sessions() + response = { + "current": { + "id": access_token.id, + "created": int(time.mktime(access_token.creation_time.timetuple())), + }, + "removed": [ + { + "id": s.id, + "name": f"{s.access_token[:8]}...", + "created": int(time.mktime(s.creation_time.timetuple())), + } for s in sessions + ] + } + await access_token.delete_sessions_without_current() + + return api_make_response(response) + + @staticmethod + @api_method("security.removeSession", + doc="Удалит сессию с указанным id. Не удаляет текущую сессию, даже если указан ее ID " + "(будет возвращена ошибка NOT FOUND)", + params=[ + ApiParamAccessToken(), + ApiParamPassword(description="Пароль пользователя, нужен для подтверждения личности"), + ApiParamInt(name="session", description="ID сессии, которую надо деактивировать", value_min=0) + ], + returns="Вернет стандартный отъект в случае успеха") + async def remove_session(access_token, password, session): + if not access_token.user.check_password(password): + raise Exception(API_ERROR_INVALID_PASSWORD) + + await access_token.delete_session(session) + + return api_make_response({}) + + @staticmethod + @api_method("security.changePassword", + doc="Смена пароля. Для подтверждения требуется старый пароль.", + params=[ + ApiParamAccessToken(), + ApiParamPassword(name="old_password", description="Старый пароль пользователя"), + ApiParamPassword(description="Новый пароль пользователя"), + ], + returns="Вернет стандартный объект успеха") + async def change_password(access_token, old_password, password): + user = access_token.user + + if not user.check_password(old_password): + raise Exception(API_ERROR_INVALID_PASSWORD, "old_password") + + user.password = password + + await sync_to_async(user.save)() + + return api_make_response({}) + + +class ApiOrder: + @staticmethod + @api_method("order.getForm", + doc="Получение формы создания заказа в виде полей, которые нужно показать пользователю", + params=[], + returns="JSON объект, содержащий данные формы. Структура пока не определена") + def get_form(): + return api_make_response({ + "fields": [ + # name = models.CharField(max_length=200, verbose_name="Название заказа") + { + "name": "name", + "label": "Название заказа", + "widget": "text", + "attrs": { + "max_len": 200 + }, + "required": True + }, + + # square = models.DecimalField(max_digits=7, decimal_places=2, blank=False, verbose_name="Площадь в м²") + { + "name": "square", + "label": "Площадь в м²", + "widget": "decimal", + "attrs": { + "max_digits": 7, + "decimal_places": 2 + }, + "required": True + }, + + # work_time = models.CharField(max_length=100, blank=True, verbose_name="Рабочее время") + { + "name": "work_time", + "label": "Рабочее время", + "widget": "text", + "attrs": { + "max_len": 100 + }, + "required": False + }, + + # type_of_renovation = models.CharField(max_length=10, choices=TYPE_OF_RENOVATION_CHOICES, + # default=CHOICE_UNDEFINED, blank=True, verbose_name="Тип ремонта") + { + "name": "type_of_renovation", + "label": "Тип ремонта", + "widget": "choice", + "attrs": { + "default": None, + "choices": "Order.TYPE_OF_RENOVATION_CHOICES" + }, + "required": False + }, + + # type_of_room = models.CharField(max_length=10, choices=TYPE_OF_ROOM_CHOICES, + # blank=True, default=CHOICE_UNDEFINED, verbose_name="Тип квартиры") + { + "name": "type_of_room", + "label": "Тип квартиры", + "widget": "radio", + "attrs": { + "default": None, + "choices": "Order.TYPE_OF_ROOM_CHOICES" + }, + "required": False + }, + + # флажок + { + "name": "is_with_warranty", + "label": "С гарантией", + "widget": "checkbox", + "attrs": { + "default": True + }, + "required": False + }, + ] + }) + + @staticmethod + @api_method("order.create", + doc="Создание заказа", + params=[ + ApiParamAccessToken(), + ApiParamStr(name='name', max_length=200, description="Название заказа"), + ApiParamStr(name='description', max_length=1000, description="Описание заказа", + required=False, default=""), + ApiParamFloat(name='square', value_max=99999.99, value_min=1.0, + description='Площадь в м²'), + ApiParamStr(name='work_time', max_length=100, description="Рабочее время", + required=False, default=""), + + ApiParamEnum(name='type_of_renovation', choices=Order.TYPE_OF_RENOVATION_CHOICES, required=False, + default=Order.CHOICE_UNDEFINED, + description="Тип ремонта: {choices}"), + ApiParamEnum(name='type_of_house', choices=Order.TYPE_OF_HOUSE_CHOICES, required=False, + default=Order.CHOICE_UNDEFINED, + description="Тип дома: {choices}"), + ApiParamEnum(name='type_of_room', choices=Order.TYPE_OF_ROOM_CHOICES, required=False, + default=Order.CHOICE_UNDEFINED, + description="Тип квартиры: {choices}"), + ApiParamEnum(name='purchase_of_material', choices=Order.PURCHASE_OF_MATERIAL_CHOICES, + required=False, default=Order.CHOICE_UNDEFINED, + description="Закуп материала: {choices}"), + ApiParamEnum(name='type_of_executor', choices=Order.TYPE_OF_EXECUTOR_CHOICES, + required=False, default=Order.CHOICE_UNDEFINED, + description="Тип исполнителя: {choices}"), + + # дальше отдельные флаги + ApiParamBoolean(name="is_with_warranty", required=False, default=True, + description="С гарантией"), + ApiParamBoolean(name="is_with_contract", required=False, default=False, + description="Работа по договору"), + ApiParamBoolean(name="is_with_trade", required=False, default=False, + description="Возможен торг"), + ApiParamBoolean(name="is_with_cleaning", required=False, default=False, + description="С уборкой"), + ApiParamBoolean(name="is_with_garbage_removal", required=False, default=False, + description="С вывозом мусора"), + ApiParamBoolean(name="is_require_design", required=False, default=False, + description="Требуется дизайн проект"), + + # примерная цена + ApiParamFloat(name='approximate_price', value_max=9999999999.99, value_min=1.0, + description='Примерная цена'), + + # date_start = models.DateField(null=True, blank=True, default=None, verbose_name="Дата начала") + # date_end = models.DateField(null=True, blank=True, default=None, verbose_name="Дата окончания") + + ApiParamEnum(name="address_city", description="Город: {choices}", choices=City.to_choices), + + ApiParamStr(name='address_text', max_length=70, description="Улица, дом", + required=False, default="") + # email = models.EmailField(null=True, blank=True, verbose_name="Email") + # phone = models.CharField(null=True, blank=True, max_length=16, verbose_name="Телефон") + ], + returns="ID созданного заказа, иначе одну из ошибок") + async def create(**kwargs): + access_token = kwargs.pop('access_token') + ApiOrder._check_modify_permissions(access_token) + + city = await City.get_by_code(kwargs.pop('address_city')) + + try: + order = await Order.objects.acreate(owner=access_token.user, address_city=city, **kwargs) + except ValidationError as ve: + return _make_model_validation_errors(ve, API_ERROR_USER_MODIFY) + return api_make_response({"order_id": order.id}) + + @staticmethod + @api_method("order.setPublished", + doc="Установка статуса публикации заказа", + params=[ + ApiParamAccessToken(), + ApiParamInt(name='order_id', value_min=0, description='ID заказа'), + ApiParamBoolean(name='value', description='Значение поля "опубликован"') + ], + returns="Обновленный объект заказа") + async def set_published(access_token, order_id, value): + ApiOrder._check_modify_permissions(access_token) + query = Order.objects.filter(id=order_id) + order = await query.afirst() + if order.owner_id != access_token.user.id: + raise Exception(API_ERROR_ACCESS_DENIED, 'edit operation allowed only for owner') + + await query.aupdate(published=value) + + # вернем в зад обновленный объект + order.published = value + return api_make_response(ApiOrder._order_to_json(order)) + + ORDER_GET_ORDERING = [ + ('create_time', 'время создания, сначала новые записи'), + ('-create_time', 'время создания, сначала старые записи'), + ] + + @staticmethod + def _order_to_json(order): + return order.__dict__ + + @staticmethod + @api_method("order.get", + doc="Получение объектов заказа, применит все фильтры", + params=[ + ApiParamAccessToken(), + ApiParamInt(name='order_id', required=False, value_min=0, + description='ID заказа, который нужно вернуть, вернет один заказ, является фильтром'), + ApiParamInt(name='user_id', required=False, value_min=0, + description='Показать заказы для конкретного пользователя'), + ApiParamEnum(name='ordering', choices=ORDER_GET_ORDERING, required=False, + description='Сортировка заказов. Возможные значения: {choices} ' + 'Не имеет эффекта если ответ состоит из одного заказа.'), + + ], + returns="Массив заказов, соответсвующий всем указанным фильтрам.") + async def get(access_token, order_id, user_id, ordering): + query = Order.objects + if order_id is not None: + res = await query.aget(id=order_id) + if user_id is not None: + if access_token.user.id == res.owner_id or (res.published and res.moderated): + return api_make_response([ApiOrder._order_to_json(res)]) + else: + raise Exception(API_ERROR_NOT_ALLOWED, 'attempt access to closed order') + + if user_id is not None: + user = await access_token.user.get_by_id(user_id) + if user is None: + raise Exception(API_ERROR_NOT_FOUND, 'user') + if user.role != Account.ROLE_CUSTOMER: + raise Exception(API_ERROR_NOT_ALLOWED, 'target user is not customer') + query = query.filter(owner_id=user_id) + + if ordering is not None: + query = query.order_by(ordering) + + return api_make_response([ApiOrder._order_to_json(item) async for item in query.all()]) + + @staticmethod + def _check_modify_permissions(access_token): + if not access_token.user.is_completed(): + raise Exception(API_ERROR_NEED_COMPLETED_ACCOUNT) + if access_token.user.role != Account.ROLE_CUSTOMER: + raise Exception(API_ERROR_NOT_ALLOWED, 'you must be a customer') + + +async def api_call_method(method_name, params: dict): + if method_name in api_methods_dict: + return await api_methods_dict[method_name]["func"](**params) + else: + return make_error_object(Exception(API_ERROR_METHOD_NOT_FOUND)) + + +def api_get_documentation(): + out = [] + for m in api_methods_dict: + out.append({ + "name": m, + "doc": api_methods_dict[m]["doc"], + "returns": api_methods_dict[m]["returns"], + "params": [p.to_json() for p in api_methods_dict[m]["params"]] + }) + return out diff --git a/api/api_params.py b/api/api_params.py new file mode 100755 index 0000000..97a7f7e --- /dev/null +++ b/api/api_params.py @@ -0,0 +1,272 @@ +import asyncio + +from .api_errors import * +from .models import AccessToken +import re + + +def _make_invalid_argument_type_error(name, value, except_type): + related = {"param_name": name, "excepted_type": except_type, "value": value} + raise Exception(API_ERROR_INVALID_ARGUMENT_TYPE, related) + + +def _make_invalid_argument_value_error(name, value, err_code, message): + related = {"param_name": name, "value": value, "error_code": err_code, "message": message} + raise Exception(API_ERROR_INVALID_ARGUMENT_VALUE, related) + + +class ApiParam: + def __init__(self, name, description, required=True, default=None): + self.name = name + self.description = description + self.required = required + self.default = default + + def validate(self, value): + """ + Валидация параметра, в случае ошибки нужно выбросить исключение, соответствующее ошибке + в случае успеха нужно вернуть объект, который и будет передан в метод в качестве параметра + """ + raise Exception(API_ERROR_INTERNAL_ERROR, f"param {self.name} have invalid definition (defined as super class)") + + def get_doc(self): + return self.description + + def get_name(self): + return self.name + + def is_required(self): + return self.required + + def get_type_name(self): + return f"{type(self)}" + + def to_json(self): + return { + "name": self.get_name(), + "type": self.get_type_name(), + "description": self.get_doc(), + "required": self.is_required() + } + + def __str__(self): + return f"{type(self)}: name={self.name}" + + +class ApiParamStr(ApiParam): + def __init__(self, regex=None, max_length=None, min_length=None, **kwargs): + super().__init__(**kwargs) + self.regex = None if regex is None else re.compile(regex) + self.max_length = max_length + self.min_length = min_length + + def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть значение по умолчанию без дополнительной проверки + return self.default + + if self.regex is not None: + if not self.regex.match(value): + _make_invalid_argument_value_error(self.name, value, "invalid", + f"expected string like this python regex: '{self.regex.pattern}'") + if self.max_length is not None: + if len(value) > self.max_length: + _make_invalid_argument_value_error(self.name, value, "long", + f"too long string. max size is '{self.max_length}' char(s)") + if self.min_length is not None: + if len(value) < self.min_length: + _make_invalid_argument_value_error(self.name, value, "short", + f"too short string. min size is '{self.max_length}' char(s)") + + return value + + def get_type_name(self): + return "String" + + +class ApiParamInt(ApiParam): + def __init__(self, value_min=None, value_max=None, **kwargs): + super().__init__(**kwargs) + self.value_max = value_max + self.value_min = value_min + + def _check_min_max(self, value): + if self.value_min is not None: + if self.value_min > value: + _make_invalid_argument_value_error(self.name, value, 'out_of_range', + f'the minimum value for this param is {self.value_min}') + if self.value_max is not None: + if self.value_max < value: + _make_invalid_argument_value_error(self.name, value, 'out_of_range', + f'the maximum value for this param is {self.value_max}') + + def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть значение по умолчанию без дополнительной проверки + return self.default + + try: + value = int(value) + except Exception: + _make_invalid_argument_type_error(self.name, value, self.get_type_name()) + + self._check_min_max(value) + + return value + + def get_doc(self): + return super().get_doc().replace('{min}', str(self.value_min)).replace('{max}', str(self.value_max)) + + def get_type_name(self): + return "Int" + + +class ApiParamEnum(ApiParam): + def __init__(self, choices, **kwargs): + super().__init__(**kwargs) + if not callable(choices): + self.choices = [] + for c in choices: + self.choices.append( + (c[0], c[1]) + ) + else: + if asyncio.iscoroutinefunction(choices): + loop = asyncio.get_event_loop() + coroutine = choices() + loop.run_until_complete(coroutine) + else: + self.choices = choices() + + async def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть значение по умолчанию без дополнительной проверки + return self.default + + for choice in self.choices: + cmp = choice[0] if type(choice[0]) is str else str(choice[0]) + if cmp == value: + return choice[0] + + _make_invalid_argument_value_error(self.name, value, 'out_of_range', + f'expected value one of {[c[0] for c in self.choices]}') + + def get_doc(self): + doc = super().get_doc() + ch = "" + return doc.replace('{choices}', ch) + + def get_type_name(self): + return "Enum" + + +class ApiParamBoolean(ApiParam): + def __init__(self, **kwargs): + super().__init__( **kwargs) + + def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть значение по умолчанию без дополнительной проверки + return self.default + + if value == 'true' or value == '1' or value == 'y': + return True + elif value == 'false' or value == '0' or value == 'n': + return False + else: + _make_invalid_argument_value_error(self.name, value, "invalid", "expected (true|y|1) or (false|n|0)") + + def get_type_name(self): + return "Boolean" + + +class ApiParamFloat(ApiParamInt): + def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть значение по умолчанию без дополнительной проверки + return self.default + + try: + value = float(value) + except Exception: + _make_invalid_argument_type_error(self.name, value, self.get_type_name()) + + self._check_min_max(value) + + return value + + def get_doc(self): + return super().get_doc().replace('{min}', str(self.value_min)).replace('{max}', str(self.value_max)) + + def get_type_name(self): + return "Float" + + +class ApiParamAccessToken(ApiParam): + def __init__(self, regex=None, name="access_token", + description="Токен, выданный методом account.auth", **kwargs): + super().__init__(name=name, description=description, **kwargs) + self.regex = (lambda: None if regex is None else re.compile(regex)) + + async def validate(self, value): + if value is None: + if self.required: + raise Exception(API_ERROR_MISSING_ARGUMENT, self.name) + else: + # вернуть None, потому что параметра нет + return None + + return await AccessToken.get_by_token(value) + + def get_type_name(self): + return "AccessToken" + + +class ApiParamPassword(ApiParamStr): + def __init__(self, name="password", description="Пароль пользователя", **kwargs): + super().__init__(name=name, description=description, regex="^[ -~а-яА-Я]{6,}$", **kwargs) + + +class ApiParamPhone(ApiParamStr): + def __init__(self, name="phone", description="Телефон в формате [[+]7]1112223333 " + "(в квадратных скобках необязательная часть)", **kwargs): + super().__init__(name=name, description=description, regex="^((\\+7)|(7)|)[0-9]{10}$", **kwargs) + + def validate(self, value): + value = super(ApiParamPhone, self).validate(value) + if value is not None: + # Гоша попросил запилить фичу, чтобы принимались номера: + # +79991112233 + # 79991112233 + # 9991112233 + if re.match("^[0-9]{10}$", value) is not None: + return f"+7{value}" + elif re.match("^7[0-9]{10}$", value) is not None: + return f"+{value}" + return value + + +class ApiParamVerifyCode(ApiParamInt): + def __init__(self, name="code", + description="Код верификации (требуется если клиенту будет отправлена " + "одна из ошибок верификации)", **kwargs): + super().__init__(name=name, required=False, value_min=0, value_max=9999, description=description, **kwargs) diff --git a/api/api_phone_verificator.py b/api/api_phone_verificator.py new file mode 100755 index 0000000..3420977 --- /dev/null +++ b/api/api_phone_verificator.py @@ -0,0 +1,162 @@ +# TODO адаптировать под работу с базой данных вместо локального словаря + +from arka.settings import PHONE_VERIFICATION_ENABLE, PHONE_VERIFICATION_ATTEMPTS,\ + PHONE_VERIFICATION_ACCESS_KEY, PHONE_VERIFICATION_RESEND_TIME_SECS + +from threading import Thread, Lock, Event +import random +import traceback +from datetime import datetime +import requests + + +class PhoneVerificationService: + __lock = Lock() + __event = Event() + + # номера, для которых отправлен запрос верификации (на внешний ресурс) + # имеет структуру: + # "{phone}": {"code": None|int|"FAILED", "attempts": int} + # None в code означает что ответ еще не пришел, остальное вроде понятно + # attempts - сколько попыток верификации осталось (по умолчанию 5) + __codes = {} + + # очередь номеров, которые требуют верификации + __to_verify = [] + __instance = None + + @staticmethod + def __service_run(): + while True: + try: + PhoneVerificationService.__event.wait() + phones = None + with PhoneVerificationService.__lock: + if PhoneVerificationService.__event.is_set(): + PhoneVerificationService.__event.clear() + phones = PhoneVerificationService.__to_verify.copy() + PhoneVerificationService.__to_verify.clear() + + if phones is not None: + for phone in phones: + # тут должна быть проверка, есть ли телефон в списке кодов + + obj = { + "code": None, + "attempts": PHONE_VERIFICATION_ATTEMPTS, + "time": datetime.now() + } + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone] = obj + request_success = False + + if PHONE_VERIFICATION_ENABLE: + try: + # параметры для sms + + params = { + "phone": lambda: phone[1:] if phone.startswith("+") else phone, + "ip": -1, + "api_id": PHONE_VERIFICATION_ACCESS_KEY + } + res = requests.get("https://sms.ru/code/call", params=params, timeout=5) + + res_json = res.json() + request_success = True + + if res_json["status"] == "OK": + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone]["code"] = res_json["code"] + print(f"Verify code for {phone}: {res_json['code']} (sms)") + else: + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone]["code"] = "FAILED" + print(f"Verify code for {phone}: FAILED (sms)") + except: + if not request_success: + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone]["code"] = "FAILED" + traceback.print_exc() + else: + try: + # для бота vk + code = random.randint(1000, 9999) + + params = { + "v": 5.131, + # "user_ids": '352634831,405800248,280108789', # Гоша, Влад, Норик + "chat_id": '1', # конфа + "access_token": PHONE_VERIFICATION_ACCESS_KEY, + "message": f"Верификация для номера {phone}
Код: {code}", + "random_id": random.randint(1000, 100000000) + } + res = requests.get("https://api.vk.com/method/messages.send", params=params, timeout=5) + # print(res.content) + request_success = True + obj["code"] = code + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone] = obj + print(f"Verify code for {phone}: {obj['code']} (vk)") + except: + if not request_success: + with PhoneVerificationService.__lock: + PhoneVerificationService.__codes[phone]["code"] = "FAILED" + traceback.print_exc() + + except: + traceback.print_exc() + + @staticmethod + def create(): + if PhoneVerificationService.__instance is None: + PhoneVerificationService.__instance = Thread(target=PhoneVerificationService.__service_run, daemon=True) + PhoneVerificationService.__instance.start() + + CHECK_PHONE_NOT_FOUND = "not-found" + CHECK_PHONE_NOT_READY = "not-ready" + CHECK_PHONE_FAILED = "failed" + CHECK_PHONE_INVALID_CODE = "invalid" + CHECK_PHONE_MAX_ATTEMPTS = "max-attempts" + CHECK_PHONE_RESEND_LIMIT = "resend" + + @staticmethod + def check_code(phone: str, code: int, auto_pop=False): + with PhoneVerificationService.__lock: + if phone not in PhoneVerificationService.__codes: + return False, PhoneVerificationService.CHECK_PHONE_NOT_FOUND + c = PhoneVerificationService.__codes[phone] + print(f"verify struct: {c}") + + if c["code"] is None: + # print(f"[PhoneVerificationService] for phone {phone} code not received yet") + return False, PhoneVerificationService.CHECK_PHONE_NOT_READY + + if c["code"] == "FAILED": + if auto_pop: + PhoneVerificationService.__codes.pop(phone) + return False, PhoneVerificationService.CHECK_PHONE_FAILED + + if c["attempts"] > 0: + if c["code"] == code: + if auto_pop: + PhoneVerificationService.__codes.pop(phone) + return True, None + + else: + # print(f"invalid code! attempts: {c['attempts']}") + PhoneVerificationService.__codes[phone]["attempts"] -= 1 + return False, PhoneVerificationService.CHECK_PHONE_INVALID_CODE + else: + return False, PhoneVerificationService.CHECK_PHONE_MAX_ATTEMPTS + + @staticmethod + def send_verify(phone: str): + with PhoneVerificationService.__lock: + if phone in PhoneVerificationService.__codes: + c = PhoneVerificationService.__codes[phone] + if (datetime.now() - c["time"]).total_seconds() <= PHONE_VERIFICATION_RESEND_TIME_SECS: + return False, PhoneVerificationService.CHECK_PHONE_RESEND_LIMIT + + PhoneVerificationService.__to_verify.append(phone) + PhoneVerificationService.__event.set() + return True, None diff --git a/api/api_utils.py b/api/api_utils.py new file mode 100755 index 0000000..3194e85 --- /dev/null +++ b/api/api_utils.py @@ -0,0 +1,83 @@ +import asyncio +from .api_errors import * +from .api_params import ApiParam + +# TODO запилить класс для параметров: телефон, пароль, почта + + +api_methods_dict = {} + + +def api_make_response(response): + return API_OK_OBJ | {"response": response} + + +# def api_make_param(name, arg_class, description, required=True): +# return { +# "name": name, +# "type": arg_class, +# "description": description, +# "required": required +# } + +# API_PARAM_ACCESS_TOKEN = api_make_param( +# "access_token", +# AccessToken, +# "Токен, выданный методом account.auth" +# ) + + +def api_method(func_name, doc="", params: list or None = None, returns=""): + """ + Декоратор для методов API, автоматически валидирует и передает параметры методам + """ + def actual_decorator(func): + async def wrapper(**kwargs): + print(f"> call method {func_name} with params {kwargs}. method params: {params}") + + errors = [] + func_args = {} + for p in params: + try: + if not isinstance(p, ApiParam): + raise Exception(API_ERROR_INTERNAL_ERROR, f"param {p} is not instance of ApiParam class") + + name = p.get_name() + value = kwargs[name] if name in kwargs else None + + if asyncio.iscoroutinefunction(p.validate): + func_args[name] = await p.validate(value) + else: + func_args[name] = p.validate(value) + + except Exception as ex: + errors.append(ex) + # print(f"errors: {errors}, args: {func_args}") + if len(errors) > 0: + if len(errors) == 1: + return make_error_object(errors[0]) + else: + return make_error_object(errors) + else: + try: + if asyncio.iscoroutinefunction(func): + out = await func(**func_args) + else: + out = func(**func_args) + except Exception as ex: + return make_error_object(ex) + + if out is None: + return make_error_object(Exception(API_ERROR_INTERNAL_ERROR, "method returned null object")) + return out + + api_methods_dict[func_name] = { + "doc": doc, + "params": params, + "func": wrapper, + "returns": returns + } + return wrapper + + return actual_decorator + diff --git a/api/apps.py b/api/apps.py new file mode 100755 index 0000000..ca84b6e --- /dev/null +++ b/api/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from .api_phone_verificator import PhoneVerificationService + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' + + def ready(self): + PhoneVerificationService.create() diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100755 index 0000000..f1eee00 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.2 on 2022-10-18 17:43 + +import datetime +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Account', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('surname', models.CharField(max_length=60, verbose_name='Фамилия')), + ('name', models.CharField(max_length=60, verbose_name='Имя')), + ('phone', models.CharField(max_length=16, unique=True, validators=[django.core.validators.RegexValidator(regex='^\\+7[0-9]{10}$')], verbose_name='Телефон')), + ('password', models.CharField(max_length=64, verbose_name='Хеш пароля')), + ('role', models.IntegerField(choices=[(0, 'Заказчик'), (1, 'Исполнитель'), (2, 'Модератор'), (3, 'Админ')])), + ('is_staff', models.BooleanField(default=False, verbose_name='Разрешение на вход в админку')), + ('register_datetime', models.DateTimeField(default=datetime.datetime.now, editable=False)), + ], + ), + migrations.CreateModel( + name='AccessToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('access_token', models.CharField(editable=False, max_length=128, unique=True)), + ('creation_time', models.DateTimeField(default=datetime.datetime.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.account')), + ], + ), + ] diff --git a/api/migrations/0002_alter_account_role_executoraccount.py b/api/migrations/0002_alter_account_role_executoraccount.py new file mode 100755 index 0000000..122b86f --- /dev/null +++ b/api/migrations/0002_alter_account_role_executoraccount.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.2 on 2022-10-20 10:54 + +import api.models +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='role', + field=models.SmallIntegerField(choices=[(0, 'Заказчик'), (1, 'Исполнитель'), (2, 'Модератор'), (3, 'Админ')]), + ), + migrations.CreateModel( + name='ExecutorAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('executor_type', models.SmallIntegerField(choices=[(0, 'самозанятый'), (1, 'юр. лицо')], verbose_name='Тип исполнителя')), + ('inn', models.CharField(max_length=10, validators=[django.core.validators.RegexValidator(regex='^[0-9]{10}$')], verbose_name='ИНН')), + ('additional_info', models.JSONField(default=api.models._executor_additional_info_default, verbose_name='Дополнительные данные')), + ('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='api.account')), + ], + ), + ] diff --git a/api/migrations/0003_remove_account_is_staff_and_more.py b/api/migrations/0003_remove_account_is_staff_and_more.py new file mode 100755 index 0000000..ae41f27 --- /dev/null +++ b/api/migrations/0003_remove_account_is_staff_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-10-20 11:03 + +import api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_alter_account_role_executoraccount'), + ] + + operations = [ + migrations.RemoveField( + model_name='account', + name='is_staff', + ), + migrations.AlterField( + model_name='executoraccount', + name='additional_info', + field=models.JSONField(blank=True, default=api.models._executor_additional_info_default, verbose_name='Дополнительные данные'), + ), + ] diff --git a/api/migrations/0004_remove_executoraccount_id_and_more.py b/api/migrations/0004_remove_executoraccount_id_and_more.py new file mode 100755 index 0000000..21911c0 --- /dev/null +++ b/api/migrations/0004_remove_executoraccount_id_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.2 on 2022-10-20 11:28 + +import api.models +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_remove_account_is_staff_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='executoraccount', + name='id', + ), + migrations.AlterField( + model_name='executoraccount', + name='account', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.account'), + ), + migrations.AlterField( + model_name='executoraccount', + name='additional_info', + field=models.JSONField(blank=True, default=api.models._executor_additional_info_default, null=True, verbose_name='Дополнительные данные'), + ), + migrations.AlterField( + model_name='executoraccount', + name='inn', + field=models.CharField(blank=True, max_length=10, null=True, validators=[django.core.validators.RegexValidator(regex='^[0-9]{10}$')], verbose_name='ИНН'), + ), + ] diff --git a/api/migrations/0005_account_about_alter_account_role.py b/api/migrations/0005_account_about_alter_account_role.py new file mode 100755 index 0000000..92ee637 --- /dev/null +++ b/api/migrations/0005_account_about_alter_account_role.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-10-21 21:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_remove_executoraccount_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='about', + field=models.CharField(blank=True, default='', max_length=1000, verbose_name='О себе'), + ), + migrations.AlterField( + model_name='account', + name='role', + field=models.SmallIntegerField(choices=[(0, 'Заказчик'), (1, 'Исполнитель')]), + ), + ] diff --git a/api/migrations/0006_alter_account_name_alter_account_password_and_more.py b/api/migrations/0006_alter_account_name_alter_account_password_and_more.py new file mode 100755 index 0000000..dcefbb6 --- /dev/null +++ b/api/migrations/0006_alter_account_name_alter_account_password_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.2 on 2022-10-26 17:20 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_account_about_alter_account_role'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='name', + field=models.CharField(blank=True, default='', max_length=60, verbose_name='Имя'), + ), + migrations.AlterField( + model_name='account', + name='password', + field=models.CharField(max_length=64, validators=[django.core.validators.RegexValidator(regex='^[\\wа-яА-Я]{6,}$')], verbose_name='Хеш пароля'), + ), + migrations.AlterField( + model_name='account', + name='role', + field=models.SmallIntegerField(blank=True, choices=[(0, 'Заказчик'), (1, 'Исполнитель')], null=True, verbose_name='Роль'), + ), + migrations.AlterField( + model_name='account', + name='surname', + field=models.CharField(blank=True, default='', max_length=60, verbose_name='Фамилия'), + ), + migrations.AlterField( + model_name='executoraccount', + name='inn', + field=models.CharField(blank=True, max_length=12, null=True, validators=[django.core.validators.RegexValidator(regex='^[0-9]{10}$|^[0-9]{12}$')], verbose_name='ИНН'), + ), + ] diff --git a/api/migrations/0007_alter_account_role_and_more.py b/api/migrations/0007_alter_account_role_and_more.py new file mode 100755 index 0000000..60e7e76 --- /dev/null +++ b/api/migrations/0007_alter_account_role_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.2 on 2022-10-26 17:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_alter_account_name_alter_account_password_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='role', + field=models.SmallIntegerField(choices=[(0, 'Заказчик'), (1, 'Исполнитель')], default=0, verbose_name='Роль'), + preserve_default=False, + ), + migrations.AlterField( + model_name='executoraccount', + name='executor_type', + field=models.SmallIntegerField(blank=True, choices=[(0, 'самозанятый'), (1, 'юр. лицо')], null=True, verbose_name='Тип исполнителя'), + ), + ] diff --git a/api/migrations/0008_account_email.py b/api/migrations/0008_account_email.py new file mode 100755 index 0000000..05b44b8 --- /dev/null +++ b/api/migrations/0008_account_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-04 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_alter_account_role_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='email', + field=models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True, verbose_name='Почта'), + ), + ] diff --git a/api/migrations/0009_city_account_city.py b/api/migrations/0009_city_account_city.py new file mode 100755 index 0000000..6e60b23 --- /dev/null +++ b/api/migrations/0009_city_account_city.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.2 on 2022-11-06 13:47 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_account_email'), + ] + + operations = [ + migrations.CreateModel( + name='City', + fields=[ + ('code', models.CharField(max_length=20, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[0-9a-zA-Z_]*$')], verbose_name='Код города')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='Название города')), + ], + ), + migrations.AddField( + model_name='account', + name='city', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.city'), + ), + ] diff --git a/api/migrations/0010_order.py b/api/migrations/0010_order.py new file mode 100755 index 0000000..74da591 --- /dev/null +++ b/api/migrations/0010_order.py @@ -0,0 +1,48 @@ +# Generated by Django 4.1.2 on 2022-11-06 15:33 + +import datetime +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0009_city_account_city'), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название заказа')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('square', models.DecimalField(decimal_places=2, max_digits=7, verbose_name='Площадь в м²')), + ('work_time', models.CharField(blank=True, max_length=100, verbose_name='Рабочее время')), + ('type_of_renovation', models.CharField(blank=True, choices=[('', 'Не определено'), ('overhaul', 'Капитальный'), ('partial', 'Частичный'), ('redecor', 'Косметический'), ('premium', 'Премиальный'), ('design', 'Дизайнерский')], default='', max_length=10, verbose_name='Тип ремонта')), + ('type_of_house', models.CharField(blank=True, choices=[('block', 'Блочный'), ('brick', 'Кирпичный'), ('monolith', 'Монолит'), ('panel', 'Панельный')], default='', max_length=10, verbose_name='Тип дома')), + ('type_of_room', models.CharField(blank=True, choices=[('primary', 'Первичка'), ('secondary', 'Вторичка')], default='', max_length=10, verbose_name='Тип квартиры')), + ('purchase_of_material', models.CharField(blank=True, choices=[('executor', 'Исполнитель'), ('customer', 'Заказчик')], default='', max_length=10, verbose_name='Закуп материала')), + ('type_of_executor', models.CharField(blank=True, choices=[('individual', 'Самозанятый/бригада'), ('company', 'Компания')], default='', max_length=10, 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(decimal_places=2, max_digits=12, verbose_name='Цена')), + ('date_start', models.DateField(blank=True, default=None, null=True, verbose_name='Дата начала')), + ('date_end', models.DateField(blank=True, default=None, null=True, verbose_name='Дата окончания')), + ('address_text', models.CharField(blank=True, max_length=70, verbose_name='Улица, дом')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(regex='^\\+7[0-9]{10}$')], verbose_name='Телефон')), + ('create_time', models.DateTimeField(default=datetime.datetime.now, editable=False, verbose_name='Время создания')), + ('moderated', models.BooleanField(default=True, verbose_name='Модерирован')), + ('published', models.BooleanField(default=False, verbose_name='Опубликован')), + ('address_city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='address_city', to='api.city', verbose_name='Город')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='api.account', verbose_name='Владелец')), + ], + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/api/migrations/__pycache__/0001_initial.cpython-311.pyc b/api/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100755 index 0000000..804b3b6 Binary files /dev/null and b/api/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0002_alter_account_role_executoraccount.cpython-311.pyc b/api/migrations/__pycache__/0002_alter_account_role_executoraccount.cpython-311.pyc new file mode 100755 index 0000000..f2c6756 Binary files /dev/null and b/api/migrations/__pycache__/0002_alter_account_role_executoraccount.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0003_remove_account_is_staff_and_more.cpython-311.pyc b/api/migrations/__pycache__/0003_remove_account_is_staff_and_more.cpython-311.pyc new file mode 100755 index 0000000..898b744 Binary files /dev/null and b/api/migrations/__pycache__/0003_remove_account_is_staff_and_more.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0004_remove_executoraccount_id_and_more.cpython-311.pyc b/api/migrations/__pycache__/0004_remove_executoraccount_id_and_more.cpython-311.pyc new file mode 100755 index 0000000..c126fc5 Binary files /dev/null and b/api/migrations/__pycache__/0004_remove_executoraccount_id_and_more.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0005_account_about_alter_account_role.cpython-311.pyc b/api/migrations/__pycache__/0005_account_about_alter_account_role.cpython-311.pyc new file mode 100755 index 0000000..d6dffd4 Binary files /dev/null and b/api/migrations/__pycache__/0005_account_about_alter_account_role.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0006_alter_account_name_alter_account_password_and_more.cpython-311.pyc b/api/migrations/__pycache__/0006_alter_account_name_alter_account_password_and_more.cpython-311.pyc new file mode 100755 index 0000000..2edb450 Binary files /dev/null and b/api/migrations/__pycache__/0006_alter_account_name_alter_account_password_and_more.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0007_alter_account_role_and_more.cpython-311.pyc b/api/migrations/__pycache__/0007_alter_account_role_and_more.cpython-311.pyc new file mode 100755 index 0000000..5016565 Binary files /dev/null and b/api/migrations/__pycache__/0007_alter_account_role_and_more.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0008_account_email.cpython-311.pyc b/api/migrations/__pycache__/0008_account_email.cpython-311.pyc new file mode 100755 index 0000000..663ce1b Binary files /dev/null and b/api/migrations/__pycache__/0008_account_email.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0009_city_account_city.cpython-311.pyc b/api/migrations/__pycache__/0009_city_account_city.cpython-311.pyc new file mode 100755 index 0000000..fa908e8 Binary files /dev/null and b/api/migrations/__pycache__/0009_city_account_city.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/0010_order.cpython-311.pyc b/api/migrations/__pycache__/0010_order.cpython-311.pyc new file mode 100755 index 0000000..5e746cd Binary files /dev/null and b/api/migrations/__pycache__/0010_order.cpython-311.pyc differ diff --git a/api/migrations/__pycache__/__init__.cpython-311.pyc b/api/migrations/__pycache__/__init__.cpython-311.pyc new file mode 100755 index 0000000..2a8c08e Binary files /dev/null and b/api/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/api/models.py b/api/models.py new file mode 100755 index 0000000..996b270 --- /dev/null +++ b/api/models.py @@ -0,0 +1,402 @@ +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) + diff --git a/api/tests.py b/api/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/urls.py b/api/urls.py new file mode 100755 index 0000000..04d6e0d --- /dev/null +++ b/api/urls.py @@ -0,0 +1,24 @@ +"""api URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.view_methods, name='view_methods'), + path('methods/', views.call_method, name='call_method') +] + diff --git a/api/views.py b/api/views.py new file mode 100755 index 0000000..191b7b0 --- /dev/null +++ b/api/views.py @@ -0,0 +1,37 @@ +import json +import traceback + +from django.shortcuts import render +from django.http import HttpResponse, HttpResponseBadRequest +from .api_methods import api_call_method, api_get_documentation + + +def view_methods(request): + methods = api_get_documentation() + return render(request, 'index.html', {'api_methods': methods}) + + +def _default_serializer(obj): + try: + return obj.to_json() + except Exception: + return str(obj) + + +async def call_method(request, method_name): + if request.method == "GET": + params = request.GET + elif request.method == "POST": + params = request.POST + else: + return HttpResponseBadRequest() + api_params = {} + for p in params: + # защита от нескольких параметров с одним именем + api_params[p] = params[p] + + out = await api_call_method(method_name, api_params) + + response = HttpResponse(json.dumps(out, default=_default_serializer, ensure_ascii=False, indent=4)) + response.headers["Content-type"] = "application/json; charset=utf-8" + return response diff --git a/arka/__init__.py b/arka/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/arka/__pycache__/__init__.cpython-311.pyc b/arka/__pycache__/__init__.cpython-311.pyc new file mode 100755 index 0000000..3d5af60 Binary files /dev/null and b/arka/__pycache__/__init__.cpython-311.pyc differ diff --git a/arka/__pycache__/asgi.cpython-311.pyc b/arka/__pycache__/asgi.cpython-311.pyc new file mode 100755 index 0000000..83e044c Binary files /dev/null and b/arka/__pycache__/asgi.cpython-311.pyc differ diff --git a/arka/__pycache__/settings.cpython-311.pyc b/arka/__pycache__/settings.cpython-311.pyc new file mode 100755 index 0000000..ee8c634 Binary files /dev/null and b/arka/__pycache__/settings.cpython-311.pyc differ diff --git a/arka/__pycache__/urls.cpython-311.pyc b/arka/__pycache__/urls.cpython-311.pyc new file mode 100755 index 0000000..bd0c0b9 Binary files /dev/null and b/arka/__pycache__/urls.cpython-311.pyc differ diff --git a/arka/__pycache__/wsgi.cpython-311.pyc b/arka/__pycache__/wsgi.cpython-311.pyc new file mode 100755 index 0000000..43f360d Binary files /dev/null and b/arka/__pycache__/wsgi.cpython-311.pyc differ diff --git a/arka/asgi.py b/arka/asgi.py new file mode 100755 index 0000000..a942dce --- /dev/null +++ b/arka/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for arka 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/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'arka.settings') + +application = get_asgi_application() diff --git a/arka/settings.py b/arka/settings.py new file mode 100755 index 0000000..b1a7e14 --- /dev/null +++ b/arka/settings.py @@ -0,0 +1,149 @@ +""" +Django settings for stall project. + +Generated by 'django-admin startproject' using Django 3.2.10. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" + +from pathlib import Path +import os +import dotenv + +dotenv.load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +PROJECT_ROOT = os.path.dirname(__file__) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["arka.topserv4824.duckdns.org", "192.168.0.160", "localhost"] +CSRF_TRUSTED_ORIGINS = ['https://arka.topserv4824.duckdns.org'] + +# Application definition + +INSTALLED_APPS = [ + 'api.apps.ApiConfig', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # 'django_extensions', +] + +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 = 'arka.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'arka.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.getenv('DB_NAME'), + 'USER': os.getenv('DB_USERNAME'), + 'PASSWORD': os.getenv('DB_PASSWORD'), + 'HOST': os.getenv('DB_HOST', 'localhost'), + 'PORT': os.getenv('DB_PORT', ''), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +# AUTH_USER_MODEL = 'api.Account' + +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', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'ru-RU' + +TIME_ZONE = 'Europe/Moscow' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = '/static/' +STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")] + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# Настройки сервиса верификации телефонов + +PHONE_VERIFICATION_ENABLE = int(os.getenv('PHONE_VERIFICATION_ENABLE', "1")) != 0 +PHONE_VERIFICATION_ATTEMPTS = int(os.getenv('PHONE_VERIFICATION_ATTEMPTS', "5")) +PHONE_VERIFICATION_RESEND_TIME_SECS = int(os.getenv('PHONE_VERIFICATION_RESEND_TIME_SECS', "180")) +PHONE_VERIFICATION_ACCESS_KEY = os.getenv('PHONE_VERIFICATION_ACCESS_KEY', "EMPTY_ACCESS_KEY") diff --git a/arka/urls.py b/arka/urls.py new file mode 100755 index 0000000..88a25b5 --- /dev/null +++ b/arka/urls.py @@ -0,0 +1,24 @@ +"""arka URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf.urls.static import static +from django.conf import settings + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('api.urls')), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/arka/wsgi.py b/arka/wsgi.py new file mode 100755 index 0000000..d725289 --- /dev/null +++ b/arka/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for arka 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/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'arka.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..508ff4e --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'arka.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..22b62c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +psycopg2 +django==4.1.7 +requests==2.28.2 +python-dotenv diff --git a/static/css/style.css b/static/css/style.css new file mode 100755 index 0000000..5013510 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,79 @@ +/* TODO исправить стили, тут верхней навигации вообще нет */ + + +/* ========== THEME ========== */ +body { + --text-color: #111; + --brand-color: #231765; + --bkg-color-blue: #0066e3; + + --bkg-color: #fff; + --bkg-color2: #ccc; + --bkg-color3: #aaa; +} + +@media (prefers-color-scheme: dark) { + /* defaults to dark theme */ + body { + --text-color: #eee; + --brand-color: #654dea; + --bkg-color-blue: #003aac; + + --bkg-color: #121212; + --bkg-color2: #202020; + --bkg-color3: #353435; + } +} + +* { + background: transparent; + color: var(--text-color); +} + +body { + background: var(--bkg-color); +} + +.page-header { + text-align: center; + margin: 1em 3em; +} + +/* ========== MAIN STYLES ========== */ + +#header-wrapper { + display: flex; + margin: 1em; +} + +#header-wrapper * { + color: var(--brand-color); +} + +header { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + margin: 0 auto; +} + +header > * { + margin: auto 0.5em; + text-decoration: none; + font-size: medium; +} + +header > div > * { + display: block; + margin: 0.1em 0; +} + +#logo-text { + font-weight: bolder; + font-size: xx-large; +} + +#logo-image { + width: 50px; + height: 50px; +} diff --git a/static/favicon.webp b/static/favicon.webp new file mode 100755 index 0000000..d40c2e6 Binary files /dev/null and b/static/favicon.webp differ diff --git a/static/images/ex1.jpg b/static/images/ex1.jpg new file mode 100644 index 0000000..8defbab Binary files /dev/null and b/static/images/ex1.jpg differ diff --git a/static/images/ex2.jpg b/static/images/ex2.jpg new file mode 100644 index 0000000..18154ba Binary files /dev/null and b/static/images/ex2.jpg differ diff --git a/static/images/ex3.jpg b/static/images/ex3.jpg new file mode 100644 index 0000000..2c3a7d1 Binary files /dev/null and b/static/images/ex3.jpg differ diff --git a/static/images/profile.png b/static/images/profile.png new file mode 100644 index 0000000..be3809f Binary files /dev/null and b/static/images/profile.png differ diff --git a/static/js/main.js b/static/js/main.js new file mode 100755 index 0000000..9ad9e2e --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,12 @@ +// скрипт... + +// Listen for a click on the button +const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)"); + +document.getElementById("theme-switcher").addEventListener("click", function () { + if (prefersDarkScheme.matches) { + document.body.classList.toggle("light-theme"); + } else { + document.body.classList.toggle("dark-theme"); + } +}); diff --git a/static/test/Untitled.ogg b/static/test/Untitled.ogg new file mode 100644 index 0000000..08cb189 Binary files /dev/null and b/static/test/Untitled.ogg differ diff --git a/static/test/index.html b/static/test/index.html new file mode 100644 index 0000000..b2a5c4b --- /dev/null +++ b/static/test/index.html @@ -0,0 +1,41 @@ + + + + + + title + + + + не все кто перешол по сылке смагли вернутса взад + + + \ No newline at end of file diff --git a/static/test/saratov.gif b/static/test/saratov.gif new file mode 100644 index 0000000..f8e6f2e Binary files /dev/null and b/static/test/saratov.gif differ diff --git a/static/test/saratov.html b/static/test/saratov.html new file mode 100644 index 0000000..8e7a5ab --- /dev/null +++ b/static/test/saratov.html @@ -0,0 +1,21 @@ + + + + + SARATOV + + + + + + + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100755 index 0000000..8827ef9 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,27 @@ + + + + + + {% block title %} Арка {% endblock %} + {% load static %} + + {% block styles %} {% endblock %} + + + +
+
+ logo image +
+ АРКА + API & Документация +
+
+
+ +
+ {% block content %} тут должен быть контент {% endblock %} +
+ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..03ceb03 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,230 @@ +{% extends 'base.html' %} +{% block title %} Арка | API Docs {% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +

Список методов API

+
+

Ну а пока ты ждешь рабочего API для заказов и портфолио, можно послушать музычку

+ +

А еще можно попробовать сделать запросы в конструкторе, он есть для каждого метода

+
+ + + + {% for method in api_methods %} +
+
+ {{ method.name }} + +
+

Описание

+

+ {{ method.doc | safe }} +

+ +

Параметры

+ {% if method.params %} +
+ + + + + + + + {% for param in method.params %} + + + + + + + {% endfor %} +
НазваниеТипОписаниеОбязательный
{{ param.name }}{{ param.type }}{{ param.description | safe }}{{ param.required }}
+ {% else %} +

+ Этот метод не принимает параметров. +

+ {% endif %} + +

Результат

+

+ {{ method.returns | safe }} +

+

+ Ссылка на метод (без параметров): {{ method.name }} +

+
+ Конструктор +
+
+
+

Параметры

+
+
+ + {% if method.params %} + {% for param in method.params %} +
+ + +
+ {% endfor %} + {% else %} +
+

+ Этот метод не принимает параметров. +

+
+ {% endif %} + +
+ +
+
+
+

Результат

+
+

+                            
+
+
+
+ +
+
+ {% endfor %} + +
+ Перейти в админку. +
+ Текущий токен:
Сбросить +
+
+ + +{% endblock %}