diff --git a/api/admin.py b/api/admin.py index ee11aff..b8f62c9 100755 --- a/api/admin.py +++ b/api/admin.py @@ -11,7 +11,7 @@ class AccountAdmin(admin.ModelAdmin): @admin.register(Media) class MediaAdmin(admin.ModelAdmin): - list_display = ['id', 'user', 'storage_name', 'original_name'] + list_display = ['id', 'owner', 'storage_name', 'original_name'] readonly_fields = ['id', 'upload_datetime'] @admin.register(ExecutorAccount) @@ -33,12 +33,6 @@ class AccessTokenAdmin(admin.ModelAdmin): 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'] diff --git a/api/api_media_utils.py b/api/api_media_utils.py new file mode 100644 index 0000000..dbccc1a --- /dev/null +++ b/api/api_media_utils.py @@ -0,0 +1,41 @@ +import boto3 +import os + +print("[DEBUG] boto3 init...") +__session = boto3.session.Session() +__s3 = __session.client( + service_name='s3', + endpoint_url='https://storage.yandexcloud.net' +) + +__bucket = os.getenv('AWS_DEFAULT_BUCKET') + + +def s3_upload_from_buffer(filename: str, data: bytes): + # Загрузить объекты в бакет + + ## Из строки + __s3.put_object(Bucket=__bucket, Key=filename, Body=data) + + +def s3_upload_from_file(s3_filename: str, target_file: str): + ## Из файла + __s3.upload_file(target_file, __bucket, s3_filename) + + +# # Получить список объектов в бакете +# for key in __s3.list_objects(Bucket='bucket-name')['Contents']: +# print(key['Key']) + + +def s3_delete(files): + forDeletion = [{'Key': 'object_name'}, {'Key': 'script/py_script.py'}] + # Удалить несколько объектов + response = __s3.delete_objects(Bucket='bucket-name', Delete={'Objects': forDeletion}) + + +def s3_get(file: str): + # Получить объект + return __s3.get_object(Bucket=__bucket, Key=file)['Body'].read() + + diff --git a/api/api_methods.py b/api/api_methods.py index fe04ef2..5d9eb78 100755 --- a/api/api_methods.py +++ b/api/api_methods.py @@ -1,8 +1,11 @@ +import random + from django.core.exceptions import ValidationError from django.http import HttpResponse, HttpResponseBadRequest from .api_utils import * from .api_params import * from .models import * +from .api_media_utils import * import time @@ -41,7 +44,7 @@ class ApiAccount: return True @staticmethod - def __make_user_json(user: Account): + def __make_user_json(user: Account, self_using=False): obj = { "id": user.id, "name": user.name, @@ -49,10 +52,19 @@ class ApiAccount: "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, + "city": {"code": user.city, "name": CITIES_CHOICES[user.city]} if user.city is not None else None, "register_datetime": int(time.mktime(user.register_datetime.timetuple())), "role": user.role, } + + if hasattr(user, 'accountavatar'): + obj["avatar"] = user.accountavatar.photo.id if user.accountavatar.photo is not None else None + obj["profile_background"] = \ + user.accountavatar.profile_background.id if user.accountavatar.photo is not None else None + else: + obj["avatar"] = None + obj["profile_background"] = None + if user.role == Account.ROLE_EXECUTOR: obj |= { "executor_type": user.executoraccount.executor_type, @@ -135,7 +147,7 @@ class ApiAccount: params=[ApiParamAccessToken()], returns="Стандартный ответ успеха, в случае успеха") async def delete(access_token): - user = access_token.user + user = access_token.owner await sync_to_async(user.delete)() return api_make_response({}) @@ -151,9 +163,9 @@ class ApiAccount: returns="Поля пользователя (name, surname, email, phone и прочие).") async def get(access_token, user_id): if user_id is None: - user = access_token.user + user = access_token.owner else: - user = await access_token.user.get_by_id(user_id) + user = await access_token.owner.get_by_id(user_id) if user is None: return make_error_object(Exception(API_ERROR_NOT_FOUND, {"user": user_id})) @@ -177,11 +189,11 @@ class ApiAccount: description="ИНН исполнителя (только для роли исполнитель): " "12 цифр если исполнитель - физ.лицо и 10 цифр если это юр. лицо"), ApiParamEnum(name="city", description="Город, в котором находится ползователь: {choices}", - required=False, choices=City.to_choices) + required=False, choices=CITIES_CHOICES) ], returns="Вернет основную информацию о пользователе, иначе ошибки") async def edit(access_token, name, surname, about, executor_type, executor_inn, city): - user = access_token.user + user = access_token.owner executor_need_save, need_save = False, False if name is not None: @@ -256,7 +268,7 @@ class ApiAccount: ], returns="Вернет стандартный объект успеха") async def change_phone(access_token, password, phone, code): - user = access_token.user + user = access_token.owner if not user.check_password(password): raise Exception(API_ERROR_INVALID_PASSWORD) @@ -310,7 +322,7 @@ class ApiAccount: class ApiSecurity: @staticmethod @api_method("security.listSessions", - doc="Получение сиписка сессий (кроме текущей)", + doc="Получение списка сессий (кроме текущей)", params=[ ApiParamAccessToken(), ApiParamPassword(description="Пароль пользователя, нужен для подтверждения личности") @@ -319,7 +331,7 @@ class ApiSecurity: async def list_sessions(access_token, password): sessions = await access_token.list_sessions() - if not access_token.user.check_password(password): + if not access_token.owner.check_password(password): raise Exception(API_ERROR_INVALID_PASSWORD) return api_make_response({ @@ -338,14 +350,14 @@ class ApiSecurity: @staticmethod @api_method("security.removeOtherSessions", - doc="Получение сиписка сессий (кроме текущей)", + 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): + if not access_token.owner.check_password(password): raise Exception(API_ERROR_INVALID_PASSWORD) sessions = await access_token.list_sessions() @@ -377,7 +389,7 @@ class ApiSecurity: ], returns="Вернет стандартный отъект в случае успеха") async def remove_session(access_token, password, session): - if not access_token.user.check_password(password): + if not access_token.owner.check_password(password): raise Exception(API_ERROR_INVALID_PASSWORD) await access_token.delete_session(session) @@ -394,7 +406,7 @@ class ApiSecurity: ], returns="Вернет стандартный объект успеха") async def change_password(access_token, old_password, password): - user = access_token.user + user = access_token.owner if not user.check_password(old_password): raise Exception(API_ERROR_INVALID_PASSWORD, "old_password") @@ -538,7 +550,7 @@ class ApiOrder: # 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), + ApiParamEnum(name="address_city", description="Город: {choices}", choices=CITIES_CHOICES), ApiParamStr(name='address_text', max_length=70, description="Улица, дом", required=False, default="") @@ -550,13 +562,11 @@ class ApiOrder: access_token = kwargs.pop('access_token') ApiOrder._check_write_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) + order = await Order.objects.acreate(owner=access_token.owner, **kwargs) + return api_make_response({"order_id": order.id}) 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", @@ -571,7 +581,7 @@ class ApiOrder: ApiOrder._check_write_permissions(access_token) query = Order.objects.filter(id=order_id) order = await query.afirst() - if order.owner_id != access_token.user.id: + if order.owner_id != access_token.owner.id: raise Exception(API_ERROR_ACCESS_DENIED, 'edit operation allowed only for owner') await query.aupdate(published=value) @@ -609,13 +619,13 @@ class ApiOrder: 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): + if access_token.owner.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) + user = await access_token.owner.get_by_id(user_id) if user is None: raise Exception(API_ERROR_NOT_FOUND, 'user') if user.role != Account.ROLE_CUSTOMER: @@ -629,29 +639,106 @@ class ApiOrder: @staticmethod def _check_write_permissions(access_token): - if not access_token.user.is_completed(): + if not access_token.owner.is_completed(): raise Exception(API_ERROR_NEED_COMPLETED_ACCOUNT) - if access_token.user.role != Account.ROLE_CUSTOMER: + if access_token.owner.role != Account.ROLE_CUSTOMER: raise Exception(API_ERROR_NOT_ALLOWED, 'you must be a customer') class ApiMedia: - # поскольку media.upload это не совсем стандартная функция, обернем фейковый метод чтоб была документация + @staticmethod + def __filename_to_ext(filename: str): + formats = { + ".jpeg": "jpeg", + ".png": "png", + ".pdf": "pdf" + } + for k in formats: + if filename.endswith(k): + return formats[k] + + return None + + @staticmethod + def __ext_to_content_type(ext: str): + formats = { + "jpeg": "image/jpeg", + "png": "image/png", + "pdf": "application/pdf" + } + if ext in formats: + return formats[ext] + return "application/binary" + @staticmethod @api_method("media.upload", - doc="Загрузка медиа на сервер. Вызывать методом POST.", + doc="Загрузка медиа на сервер. Вызывать методом POST. Обязательно должен быть прикреплен файл" + " с именем поля 'file'", params=[ ApiRequestParam(), ApiParamAccessToken(), - ApiParamStr(name="filename", description="Название файла", - min_length=5, max_length=60), ], returns="id медиа, в противном случае ошибку") - def upload(request, access_token, filename): - # ну шож, метод фейковый, все проверки нужно сделать руками + async def upload(request, access_token): if request.method != "POST": return make_error_object(Exception(API_ERROR_INVALID_REQUEST, "method must be executed http POST method")) - return HttpResponse("Да пошел ты нах со своим аплоадом") + if 'file' not in request.FILES: + return make_error_object(Exception(API_ERROR_INVALID_REQUEST, + "you must attach file with field-name 'file'")) + + filename = request.FILES['file'].name + print(filename, type(filename)) + + # if not access_token.user.is_completed(): + # return make_error_object(Exception(API_ERROR_NEED_COMPLETED_ACCOUNT)) + ext = ApiMedia.__filename_to_ext(filename) + if ext is None: + return make_error_object(Exception(API_ERROR_INVALID_REQUEST, "unsupported file extension")) + + try: + storage_name = Media.generate_storage_name(filename, datetime.now(), access_token.owner) + + await sync_to_async(s3_upload_from_buffer)(storage_name, request.FILES['file'].read()) + + m = await Media.objects.acreate(user=access_token.owner, original_name=filename, + extension=ext, storage_name=storage_name) + return api_make_response({'media_id': m.id}) + except Exception: + traceback.print_exc() + + return make_error_object(Exception(API_ERROR_INTERNAL_ERROR, "try to upload file after 5 minutes")) + + @staticmethod + @api_method("media.get", + doc="Получение медиа", + params=[ + ApiRequestParam(), + ApiParamAccessToken(), + ApiParamInt(name="m_id", description="ID медиа", + value_min=0, value_max=1000000000), + ], returns="медиа, в противном случае ошибку") + async def get(request, access_token, m_id): + if request.method != "GET": + return make_error_object(Exception(API_ERROR_INVALID_REQUEST, "method must be executed http GET method")) + + m = await Media.objects.filter(owner=access_token.user, pk=m_id).afirst() + + if m is not None: + try: + # Media.get_by_id(access_token.user, m_id) + res = HttpResponse(content=s3_get(m.storage_name)) + res.headers["Content-type"] = "image/jpg" + return res + except Exception: + traceback.print_exc() + return make_error_object(Exception(API_ERROR_NOT_FOUND, "object in storage not found")) + + +class DatabaseApi: + # TODO переместить сюда форму заказа, список городов + # def get_order_form + # def get_citys + pass async def api_call_method(request, method_name, params: dict): diff --git a/api/models.py b/api/models.py index 2f1423e..160cf66 100755 --- a/api/models.py +++ b/api/models.py @@ -8,39 +8,11 @@ from hashlib import sha512, sha256 from django.db.models import Q -from django.db.utils import ProgrammingError - +from arka.settings import CITIES_CHOICES, CITIES_FIELD_SIZE 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(): - try: - return list(City.objects.order_by('name').values_list('code', 'name')) - except ProgrammingError: - return [] - - @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="") @@ -65,7 +37,7 @@ class Account(models.Model): 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) + city = models.CharField(max_length=CITIES_FIELD_SIZE, choices=CITIES_CHOICES, default=None, null=True, blank=True) register_datetime = models.DateTimeField(default=datetime.now, editable=False) @@ -94,7 +66,7 @@ class Account(models.Model): 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') + u = Account.objects.filter(id=user_id).select_related('executoraccount', 'accountavatar') if self.role == Account.ROLE_EXECUTOR or self.role == Account.ROLE_CUSTOMER: u.filter(role__in=[Account.ROLE_EXECUTOR, Account.ROLE_CUSTOMER]) @@ -117,13 +89,16 @@ class Account(models.Model): 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 + return self.name != '' and self.surname != '' and self.city is not None class Media(models.Model): - user = models.ForeignKey(Account, on_delete=models.SET_NULL, null=True) - storage_name = models.CharField(max_length=100, verbose_name="Файл в хранилище") + owner = models.ForeignKey(Account, on_delete=models.SET_NULL, null=True, + related_name="media_owner", verbose_name="Владелец") + storage_name = models.CharField(max_length=80, verbose_name="Файл в хранилище") original_name = models.CharField(max_length=100, verbose_name="Имя файла", default="") + extension = models.CharField(max_length=16) + size = models.IntegerField(verbose_name='Размер в байтах') upload_datetime = models.DateTimeField(default=datetime.now, editable=False) @staticmethod @@ -135,13 +110,21 @@ class Media(models.Model): async def get_media(): pass - def generate_storage_name(self): - if self.storage_name is None: - source_str = f"{self.original_name} {self.original_name} {self.upload_datetime} {self.user.id}" - self.storage_name = sha512(bytearray(source_str, 'utf-8')).hexdigest() + @staticmethod + def generate_storage_name(original_name, upload_datetime, user_id): + source_str = f"{original_name} {upload_datetime} {user_id}" + return sha256(bytearray(source_str, 'utf-8')).hexdigest() def __str__(self): - return f"{self.user}: \"{self.original_name}\" ({self.id})" + return f"{self.owner}: \"{self.original_name}\" ({self.id})" + + +class AccountAvatar(models.Model): + account = models.OneToOneField(Account, on_delete=models.CASCADE, related_name="account", verbose_name="Аккаунт") + photo = models.ForeignKey(Media, on_delete=models.SET_NULL, null=True, + related_name="photo", verbose_name="Аватар") + profile_background = models.ForeignKey(Media, on_delete=models.SET_NULL, null=True, default=None, + related_name="profile_background", verbose_name="Оформление профиля") def _executor_additional_info_default(): @@ -205,12 +188,12 @@ class AccessToken(models.Model): @staticmethod async def get_user_by_token(token: str): - return (await AccessToken.get_by_token(token)).user + return (await AccessToken.get_by_token(token)).owner @staticmethod async def get_by_token(token: str): - t = await AccessToken.objects.filter(access_token=token)\ - .select_related('user', 'user__executoraccount', 'user__city').afirst() + t = await AccessToken.objects.filter(access_token=token).select_related('user', + 'user__executoraccount').afirst() if t is None: raise Exception(API_ERROR_INVALID_TOKEN) return t @@ -342,8 +325,8 @@ class Order(models.Model): 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_city = models.CharField(max_length=CITIES_FIELD_SIZE, choices=CITIES_CHOICES, + blank=False, 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="Владелец") @@ -377,7 +360,6 @@ class Order(models.Model): 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 @@ -428,4 +410,3 @@ class Order(models.Model): # self.full_clean() # # super().save(*args, **kwargs) - diff --git a/api/views.py b/api/views.py index 7b27865..bde1cb9 100755 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,8 @@ import json from django.shortcuts import render from django.http import HttpResponse, HttpResponseBadRequest +from django.views.decorators.csrf import csrf_exempt + from .api_methods import api_call_method, api_get_documentation @@ -36,4 +38,3 @@ async def call_method(request, method_name): return response else: return out - diff --git a/arka/settings.py b/arka/settings.py index 3e51083..0c82097 100755 --- a/arka/settings.py +++ b/arka/settings.py @@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ from pathlib import Path import os import dotenv +import json + dotenv.load_dotenv() @@ -49,7 +51,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -147,3 +149,11 @@ PHONE_VERIFICATION_ENABLE = int(os.getenv('PHONE_VERIFICATION_ENABLE', "1")) != 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") + + +# настройки выбора городов +with open('config/cities.json') as f: + CITIES_CHOICES = json.load(f) + for i in range(0, len(CITIES_CHOICES)): + CITIES_CHOICES[i] = tuple(CITIES_CHOICES[i]) +CITIES_FIELD_SIZE = 16 diff --git a/config/cities.json b/config/cities.json new file mode 100644 index 0000000..ad11797 --- /dev/null +++ b/config/cities.json @@ -0,0 +1,6 @@ +[ + ["moscow", "Москва"], + ["mytishchi", "Мытищи"], + ["belgorod", "Белгород"], + ["orel", "Орел"] +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 22b62c1..7c5452e 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ psycopg2 django==4.1.7 requests==2.28.2 python-dotenv +boto3