initial commit
This commit is contained in:
commit
b02b9e1811
0
api/__init__.py
Executable file
0
api/__init__.py
Executable file
BIN
api/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
api/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/admin.cpython-311.pyc
Executable file
BIN
api/__pycache__/admin.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/api_errors.cpython-311.pyc
Executable file
BIN
api/__pycache__/api_errors.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/api_methods.cpython-311.pyc
Normal file
BIN
api/__pycache__/api_methods.cpython-311.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/api_params.cpython-311.pyc
Executable file
BIN
api/__pycache__/api_params.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/api_phone_verificator.cpython-311.pyc
Normal file
BIN
api/__pycache__/api_phone_verificator.cpython-311.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/api_utils.cpython-311.pyc
Executable file
BIN
api/__pycache__/api_utils.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/apps.cpython-311.pyc
Executable file
BIN
api/__pycache__/apps.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/models.cpython-311.pyc
Executable file
BIN
api/__pycache__/models.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/urls.cpython-311.pyc
Executable file
BIN
api/__pycache__/urls.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/__pycache__/views.cpython-311.pyc
Executable file
BIN
api/__pycache__/views.cpython-311.pyc
Executable file
Binary file not shown.
50
api/admin.py
Executable file
50
api/admin.py
Executable file
@ -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
|
97
api/api_errors.py
Executable file
97
api/api_errors.py
Executable file
@ -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, <related_obj>)
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
}
|
||||||
|
}
|
653
api/api_methods.py
Executable file
653
api/api_methods.py
Executable file
@ -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="Аналогично методу <code>account.auth</code> в случае успеха")
|
||||||
|
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="В случае правильных логина и пароля <code>access_token</code>. "
|
||||||
|
"В противном случае объект ошибки.")
|
||||||
|
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='Удаление аккаунта пользователя <bold style="color:red">БЕЗ ВОЗМОЖНОСТИ ВОССТАНОВЛЕНИЯ</bold>. '
|
||||||
|
'Так же будут удалены <bold style="color:red">ВСЕ</bold> связанные с аккаунтом данные.',
|
||||||
|
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="<code>JSON</code> объект, содержащий данные формы. Структура пока не определена")
|
||||||
|
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
|
272
api/api_params.py
Executable file
272
api/api_params.py
Executable file
@ -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 = "<ul>"
|
||||||
|
for c in self.choices:
|
||||||
|
if c[0] != '':
|
||||||
|
ch += f"<li>{c[0]} - {c[1]}</li>"
|
||||||
|
ch += "</ul>"
|
||||||
|
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="<i>Токен</i>, выданный методом <code>account.auth</code>", **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="Телефон в формате <code>[[+]7]1112223333</code> "
|
||||||
|
"<i>(в квадратных скобках необязательная часть)</i>", **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="Код верификации (требуется если клиенту будет отправлена "
|
||||||
|
"одна из ошибок <i>верификации</i>)", **kwargs):
|
||||||
|
super().__init__(name=name, required=False, value_min=0, value_max=9999, description=description, **kwargs)
|
162
api/api_phone_verificator.py
Executable file
162
api/api_phone_verificator.py
Executable file
@ -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}<br>Код: {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
|
83
api/api_utils.py
Executable file
83
api/api_utils.py
Executable file
@ -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,
|
||||||
|
# "<i>Токен</i>, выданный методом <code>account.auth</code>"
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
10
api/apps.py
Executable file
10
api/apps.py
Executable file
@ -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()
|
39
api/migrations/0001_initial.py
Executable file
39
api/migrations/0001_initial.py
Executable file
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
31
api/migrations/0002_alter_account_role_executoraccount.py
Executable file
31
api/migrations/0002_alter_account_role_executoraccount.py
Executable file
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
23
api/migrations/0003_remove_account_is_staff_and_more.py
Executable file
23
api/migrations/0003_remove_account_is_staff_and_more.py
Executable file
@ -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='Дополнительные данные'),
|
||||||
|
),
|
||||||
|
]
|
35
api/migrations/0004_remove_executoraccount_id_and_more.py
Executable file
35
api/migrations/0004_remove_executoraccount_id_and_more.py
Executable file
@ -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='ИНН'),
|
||||||
|
),
|
||||||
|
]
|
23
api/migrations/0005_account_about_alter_account_role.py
Executable file
23
api/migrations/0005_account_about_alter_account_role.py
Executable file
@ -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, 'Исполнитель')]),
|
||||||
|
),
|
||||||
|
]
|
39
api/migrations/0006_alter_account_name_alter_account_password_and_more.py
Executable file
39
api/migrations/0006_alter_account_name_alter_account_password_and_more.py
Executable file
@ -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='ИНН'),
|
||||||
|
),
|
||||||
|
]
|
24
api/migrations/0007_alter_account_role_and_more.py
Executable file
24
api/migrations/0007_alter_account_role_and_more.py
Executable file
@ -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='Тип исполнителя'),
|
||||||
|
),
|
||||||
|
]
|
18
api/migrations/0008_account_email.py
Executable file
18
api/migrations/0008_account_email.py
Executable file
@ -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='Почта'),
|
||||||
|
),
|
||||||
|
]
|
27
api/migrations/0009_city_account_city.py
Executable file
27
api/migrations/0009_city_account_city.py
Executable file
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
48
api/migrations/0010_order.py
Executable file
48
api/migrations/0010_order.py
Executable file
@ -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='Владелец')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
api/migrations/__init__.py
Executable file
0
api/migrations/__init__.py
Executable file
BIN
api/migrations/__pycache__/0001_initial.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0001_initial.cpython-311.pyc
Executable file
Binary file not shown.
Binary file not shown.
BIN
api/migrations/__pycache__/0003_remove_account_is_staff_and_more.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0003_remove_account_is_staff_and_more.cpython-311.pyc
Executable file
Binary file not shown.
Binary file not shown.
BIN
api/migrations/__pycache__/0005_account_about_alter_account_role.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0005_account_about_alter_account_role.cpython-311.pyc
Executable file
Binary file not shown.
Binary file not shown.
BIN
api/migrations/__pycache__/0007_alter_account_role_and_more.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0007_alter_account_role_and_more.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/migrations/__pycache__/0008_account_email.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0008_account_email.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/migrations/__pycache__/0009_city_account_city.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0009_city_account_city.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/migrations/__pycache__/0010_order.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/0010_order.cpython-311.pyc
Executable file
Binary file not shown.
BIN
api/migrations/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
api/migrations/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
402
api/models.py
Executable file
402
api/models.py
Executable file
@ -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)
|
||||||
|
|
3
api/tests.py
Executable file
3
api/tests.py
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
24
api/urls.py
Executable file
24
api/urls.py
Executable file
@ -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/<str:method_name>', views.call_method, name='call_method')
|
||||||
|
]
|
||||||
|
|
37
api/views.py
Executable file
37
api/views.py
Executable file
@ -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
|
0
arka/__init__.py
Executable file
0
arka/__init__.py
Executable file
BIN
arka/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
arka/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
arka/__pycache__/asgi.cpython-311.pyc
Executable file
BIN
arka/__pycache__/asgi.cpython-311.pyc
Executable file
Binary file not shown.
BIN
arka/__pycache__/settings.cpython-311.pyc
Executable file
BIN
arka/__pycache__/settings.cpython-311.pyc
Executable file
Binary file not shown.
BIN
arka/__pycache__/urls.cpython-311.pyc
Executable file
BIN
arka/__pycache__/urls.cpython-311.pyc
Executable file
Binary file not shown.
BIN
arka/__pycache__/wsgi.cpython-311.pyc
Executable file
BIN
arka/__pycache__/wsgi.cpython-311.pyc
Executable file
Binary file not shown.
16
arka/asgi.py
Executable file
16
arka/asgi.py
Executable file
@ -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()
|
149
arka/settings.py
Executable file
149
arka/settings.py
Executable file
@ -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")
|
24
arka/urls.py
Executable file
24
arka/urls.py
Executable file
@ -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)
|
16
arka/wsgi.py
Executable file
16
arka/wsgi.py
Executable file
@ -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()
|
22
manage.py
Executable file
22
manage.py
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '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()
|
4
requirements.txt
Executable file
4
requirements.txt
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
psycopg2
|
||||||
|
django==4.1.7
|
||||||
|
requests==2.28.2
|
||||||
|
python-dotenv
|
79
static/css/style.css
Executable file
79
static/css/style.css
Executable file
@ -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;
|
||||||
|
}
|
BIN
static/favicon.webp
Executable file
BIN
static/favicon.webp
Executable file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
static/images/ex1.jpg
Normal file
BIN
static/images/ex1.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 387 KiB |
BIN
static/images/ex2.jpg
Normal file
BIN
static/images/ex2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
static/images/ex3.jpg
Normal file
BIN
static/images/ex3.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 540 KiB |
BIN
static/images/profile.png
Normal file
BIN
static/images/profile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 132 KiB |
12
static/js/main.js
Executable file
12
static/js/main.js
Executable file
@ -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");
|
||||||
|
}
|
||||||
|
});
|
BIN
static/test/Untitled.ogg
Normal file
BIN
static/test/Untitled.ogg
Normal file
Binary file not shown.
41
static/test/index.html
Normal file
41
static/test/index.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
@keyframes blinking {
|
||||||
|
0% {
|
||||||
|
background-color: #ff3d50;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-color: #55d66b;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
background-color: #d0b91d;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: #222291;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
animation: blinking 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: aliceblue;
|
||||||
|
font-size: 100px;
|
||||||
|
font-size: 100px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title> title </title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="saratov.html"> не все кто перешол по сылке смагли вернутса взад </a>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
static/test/saratov.gif
Normal file
BIN
static/test/saratov.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
21
static/test/saratov.html
Normal file
21
static/test/saratov.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title> SARATOV </title>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
background-color: black;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<iframe src="Untitled.ogg" type="audio/ogg" allow="autoplay" id="audio" style="display:none"></iframe>
|
||||||
|
<img src="saratov.gif">
|
||||||
|
</body>
|
||||||
|
</html>
|
27
templates/base.html
Executable file
27
templates/base.html
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title> {% block title %} Арка {% endblock %} </title>
|
||||||
|
{% load static %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">
|
||||||
|
{% block styles %} {% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="header-wrapper">
|
||||||
|
<header>
|
||||||
|
<img id="logo-image" src="{% static 'favicon.webp' %}" alt="logo image">
|
||||||
|
<div>
|
||||||
|
<span id="logo-text">АРКА</span>
|
||||||
|
<span>API & Документация</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="content">
|
||||||
|
{% block content %} тут должен быть контент {% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
230
templates/index.html
Executable file
230
templates/index.html
Executable file
@ -0,0 +1,230 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %} Арка | API Docs {% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.table-wrapper, .constructor-result > pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
border: var(--brand-color) solid 1px;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
details > div {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: var(--brand-color) solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-div {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-div summary {
|
||||||
|
font-weight: bolder;
|
||||||
|
font-size: x-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================== */
|
||||||
|
|
||||||
|
.constructor-wrapper {
|
||||||
|
background: var(--bkg-color2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constructor-fields {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constructor-param {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
display: block;
|
||||||
|
margin: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constructor-param > * {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constructor-param > input, .constructor-param > button {
|
||||||
|
border: var(--brand-color) solid 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constructor-result {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="page-header"> Список методов API </h1>
|
||||||
|
<div>
|
||||||
|
<p> Ну а пока ты ждешь рабочего API для заказов и портфолио, можно послушать музычку </p>
|
||||||
|
<audio preload="none" controls src="/m.mp3"></audio>
|
||||||
|
<p> А еще можно попробовать сделать запросы в конструкторе, он есть для каждого метода </p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getAccessToken(new_value) {
|
||||||
|
if (new_value === undefined || new_value === null || new_value === "") {
|
||||||
|
let res = localStorage.getItem("access_token")
|
||||||
|
if (res === null) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
} else {
|
||||||
|
console.log(`Storing ${new_value} as token`)
|
||||||
|
localStorage.setItem("access_token", new_value)
|
||||||
|
document.getElementById('current_access_token').innerText = new_value
|
||||||
|
return new_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function sendRequest(method, params) {
|
||||||
|
let url = `/methods/${method}`
|
||||||
|
if (params !== undefined && params !== null) {
|
||||||
|
url += "?" + new URLSearchParams(params)
|
||||||
|
}
|
||||||
|
return await fetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRequest(view, method, inputs) {
|
||||||
|
let params = {}
|
||||||
|
for (let k in inputs) {
|
||||||
|
let element = document.getElementById(inputs[k])
|
||||||
|
const name = element.name
|
||||||
|
{#let val = encodeURIComponent(element.value)#}
|
||||||
|
let val = element.value
|
||||||
|
if (name === "access_token") {
|
||||||
|
val = getAccessToken(val)
|
||||||
|
}
|
||||||
|
if (val.length > 0)
|
||||||
|
params[name] = val
|
||||||
|
}
|
||||||
|
let res = await sendRequest(method, params)
|
||||||
|
const text = await res.text()
|
||||||
|
document.getElementById(view).innerText = text
|
||||||
|
|
||||||
|
// чтобы запоминался токен
|
||||||
|
try {
|
||||||
|
let j = JSON.parse(text)
|
||||||
|
getAccessToken(j["response"]["access_token"])
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% for method in api_methods %}
|
||||||
|
<div class="method-div">
|
||||||
|
<details>
|
||||||
|
<summary>{{ method.name }}</summary>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>Описание</h3>
|
||||||
|
<p>
|
||||||
|
{{ method.doc | safe }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Параметры</h3>
|
||||||
|
{% if method.params %}
|
||||||
|
<div class="table-wrapper"><table>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Обязательный</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for param in method.params %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ param.name }}</td>
|
||||||
|
<td>{{ param.type }}</td>
|
||||||
|
<td>{{ param.description | safe }}</td>
|
||||||
|
<td>{{ param.required }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table></div>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
Этот метод не принимает параметров.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>Результат</h3>
|
||||||
|
<p>
|
||||||
|
{{ method.returns | safe }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ссылка на метод (без параметров): <a href="/methods/{{ method.name }}">{{ method.name }}</a>
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>Конструктор</summary>
|
||||||
|
<div class="constructor-wrapper" id="view-{{ method.name }}">
|
||||||
|
<div class="constructor-fields">
|
||||||
|
<div style="">
|
||||||
|
<h3>Параметры</h3>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if method.params %}
|
||||||
|
{% for param in method.params %}
|
||||||
|
<div class="constructor-param">
|
||||||
|
<label for="param-{{ method.name }}-{{ param.name }}">{{ param.name }}</label>
|
||||||
|
<input type="text" name="{{ param.name }}" id="param-{{ method.name }}-{{ param.name }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="constructor-param">
|
||||||
|
<p>
|
||||||
|
Этот метод не принимает параметров.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="constructor-param">
|
||||||
|
<button onclick="makeRequest('result-{{ method.name }}', '{{ method.name }}',
|
||||||
|
[{% for param in method.params %}'param-{{ method.name }}-{{ param.name }}', {% endfor %}])">Выполнить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="constructor-result">
|
||||||
|
<h3>Результат</h3>
|
||||||
|
<hr>
|
||||||
|
<pre id="result-{{ method.name }}"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div style="text-align: center; background: var(--bkg-color2); margin: 0; margin-top: 3em; padding: 2em; overflow-wrap: break-word;">
|
||||||
|
Перейти в <a href="/admin">админку</a>.
|
||||||
|
<div>
|
||||||
|
Текущий токен: <i id="current_access_token"></i><br><a onclick="localStorage.clear(); document.getElementById('current_access_token').innerText = ''">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.onload = (event) => {
|
||||||
|
const at = localStorage.getItem("access_token")
|
||||||
|
if (at !== null) {
|
||||||
|
document.getElementById('current_access_token').innerText = at
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
Reference in New Issue
Block a user