From 75234091b618fa5e24951d5414300b484d930be1 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 8 Sep 2022 06:44:54 +0300 Subject: [PATCH] + --- Dockerfile | 23 + Procfile | 2 + bagchat/__init__.py | 3 + bagchat/asgi.py | 34 + bagchat/beatconfig.py | 16 + bagchat/celery.py | 8 + bagchat/settings.py | 169 ++++ bagchat/urls.py | 15 + bagchat/wsgi.py | 16 + base/__init__.py | 0 base/admin.py | 55 ++ base/apps.py | 7 + base/migrations/0001_initial.py | 53 ++ base/migrations/0002_imageslink.py | 26 + ...er_imageslink_photo_alter_profile_photo.py | 24 + base/migrations/0004_uploadfilecontainer.py | 25 + .../0005_delete_uploadfilecontainer.py | 16 + base/migrations/__init__.py | 0 base/models.py | 143 +++ base/serializers.py | 63 ++ base/service.py | 19 + base/tests.py | 3 + base/urls.py | 13 + base/views.py | 199 +++++ chat/AuthMiddlewareToken.py | 28 + chat/__init__.py | 0 chat/admin.py | 3 + chat/apps.py | 5 + chat/consumers.py | 840 ++++++++++++++++++ chat/models.py | 1 + chat/routing.py | 5 + chat/serializers.py | 251 ++++++ chat/tests.py | 3 + chat/urls.py | 3 + chat/views.py | 8 + docker-compose.yml | 57 ++ genie/__init__.py | 0 genie/admin.py | 76 ++ genie/apps.py | 5 + genie/migrations/0001_initial.py | 93 ++ genie/migrations/__init__.py | 0 genie/models.py | 121 +++ genie/tests.py | 3 + genie/urls.py | 8 + genie/views.py | 460 ++++++++++ heroku.yml | 11 + manage.py | 22 + media/__init__.py | 0 portfolio/__init__.py | 0 portfolio/admin.py | 77 ++ portfolio/apps.py | 7 + portfolio/migrations/0001_initial.py | 185 ++++ .../migrations/0002_alter_workpost_photo.py | 19 + portfolio/migrations/0003_delete_mytags.py | 16 + .../0004_alter_message_room_notifymsg.py | 39 + ...project_alter_comment_response_and_more.py | 35 + ...lter_raiting_project_alter_raiting_user.py | 26 + portfolio/migrations/__init__.py | 0 portfolio/models.py | 297 +++++++ portfolio/router.py | 19 + portfolio/serializers.py | 462 ++++++++++ portfolio/tests.py | 3 + portfolio/urls.py | 58 ++ portfolio/views.py | 778 ++++++++++++++++ requirements.txt | 74 ++ resum.docx | Bin 0 -> 143753 bytes runtime.txt | 1 + russian_names.json | 1 + setmessage.txt | 56 ++ staticfiles/__init__.py | 0 70 files changed, 5088 insertions(+) create mode 100644 Dockerfile create mode 100644 Procfile create mode 100644 bagchat/__init__.py create mode 100644 bagchat/asgi.py create mode 100644 bagchat/beatconfig.py create mode 100644 bagchat/celery.py create mode 100644 bagchat/settings.py create mode 100644 bagchat/urls.py create mode 100644 bagchat/wsgi.py create mode 100644 base/__init__.py create mode 100644 base/admin.py create mode 100644 base/apps.py create mode 100644 base/migrations/0001_initial.py create mode 100644 base/migrations/0002_imageslink.py create mode 100644 base/migrations/0003_alter_imageslink_photo_alter_profile_photo.py create mode 100644 base/migrations/0004_uploadfilecontainer.py create mode 100644 base/migrations/0005_delete_uploadfilecontainer.py create mode 100644 base/migrations/__init__.py create mode 100644 base/models.py create mode 100644 base/serializers.py create mode 100644 base/service.py create mode 100644 base/tests.py create mode 100644 base/urls.py create mode 100644 base/views.py create mode 100644 chat/AuthMiddlewareToken.py create mode 100644 chat/__init__.py create mode 100644 chat/admin.py create mode 100644 chat/apps.py create mode 100644 chat/consumers.py create mode 100644 chat/models.py create mode 100644 chat/routing.py create mode 100644 chat/serializers.py create mode 100644 chat/tests.py create mode 100644 chat/urls.py create mode 100644 chat/views.py create mode 100644 docker-compose.yml create mode 100644 genie/__init__.py create mode 100644 genie/admin.py create mode 100644 genie/apps.py create mode 100644 genie/migrations/0001_initial.py create mode 100644 genie/migrations/__init__.py create mode 100644 genie/models.py create mode 100644 genie/tests.py create mode 100644 genie/urls.py create mode 100644 genie/views.py create mode 100644 heroku.yml create mode 100644 manage.py create mode 100644 media/__init__.py create mode 100644 portfolio/__init__.py create mode 100644 portfolio/admin.py create mode 100644 portfolio/apps.py create mode 100644 portfolio/migrations/0001_initial.py create mode 100644 portfolio/migrations/0002_alter_workpost_photo.py create mode 100644 portfolio/migrations/0003_delete_mytags.py create mode 100644 portfolio/migrations/0004_alter_message_room_notifymsg.py create mode 100644 portfolio/migrations/0005_alter_comment_project_alter_comment_response_and_more.py create mode 100644 portfolio/migrations/0006_alter_raiting_project_alter_raiting_user.py create mode 100644 portfolio/migrations/__init__.py create mode 100644 portfolio/models.py create mode 100644 portfolio/router.py create mode 100644 portfolio/serializers.py create mode 100644 portfolio/tests.py create mode 100644 portfolio/urls.py create mode 100644 portfolio/views.py create mode 100644 requirements.txt create mode 100644 resum.docx create mode 100644 runtime.txt create mode 100644 russian_names.json create mode 100644 setmessage.txt create mode 100644 staticfiles/__init__.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1253300 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.10-alpine as builder-image +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +RUN apk update && apk add postgresql-dev gcc python3-dev \ + musl-dev libffi-dev libevent-dev libart-lgpl zlib-dev \ + libxslt-dev zlib libc-dev linux-headers freetype-dev pgbouncer +RUN pip install --upgrade pip +WORKDIR /usr/src/app +COPY ./requirements.txt . +RUN pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements.txt + + +FROM python:3.10-alpine +WORKDIR /app +RUN apk update && apk add freetype-dev libpq pgbouncer +COPY --from=builder-image /usr/src/app/wheels /wheels +COPY --from=builder-image /usr/src/app/requirements.txt . +RUN pip install --no-cache /wheels/* +COPY . . +RUN chmod +x ./compile +RUN ls -l +RUN ./compile /app +RUN python manage.py collectstatic --noinput \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..c87b629 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: daphne bagchat.asgi:application -p $PORT -b 0.0.0.0 --proxy-headers +worker: python manage.py beatserver \ No newline at end of file diff --git a/bagchat/__init__.py b/bagchat/__init__.py new file mode 100644 index 0000000..22da6da --- /dev/null +++ b/bagchat/__init__.py @@ -0,0 +1,3 @@ +# from .celery import app as celery_app + +# __all__ = ['celery_app'] \ No newline at end of file diff --git a/bagchat/asgi.py b/bagchat/asgi.py new file mode 100644 index 0000000..743e85e --- /dev/null +++ b/bagchat/asgi.py @@ -0,0 +1,34 @@ +""" +ASGI config for bagchat 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.0/howto/deployment/asgi/ +""" + +import os +from django.core.asgi import get_asgi_application +django_asgi_app = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter, ChannelNameRouter +from channels.security.websocket import AllowedHostsOriginValidator +import chat.routing +from chat.AuthMiddlewareToken import AuthMiddlewareToken +from base.views import EntryPointTasks +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bagchat.settings') +os.environ['ASGI_THREADS']="4" +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": + AllowedHostsOriginValidator( + AuthMiddlewareToken( + URLRouter( + chat.routing.websocket_urlpatterns + ) + ) + ), + "channel": ChannelNameRouter({ + "channel-tasks": EntryPointTasks.as_asgi(), + }), +}) \ No newline at end of file diff --git a/bagchat/beatconfig.py b/bagchat/beatconfig.py new file mode 100644 index 0000000..a60aa94 --- /dev/null +++ b/bagchat/beatconfig.py @@ -0,0 +1,16 @@ +from datetime import timedelta +BEAT_SCHEDULE = { + 'channel-tasks': [ + { + 'type': 'test.holidays', + 'message': None, + 'schedule': '0 3 * * *' + # 'schedule': timedelta(seconds=5) + } + # { + # 'type': 'chat_force_test', + # 'message': None, + # # 'schedule': timedelta(seconds=5) + # }, + ] +} \ No newline at end of file diff --git a/bagchat/celery.py b/bagchat/celery.py new file mode 100644 index 0000000..c505900 --- /dev/null +++ b/bagchat/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bagchat.settings') + +app = Celery('bagchat') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() \ No newline at end of file diff --git a/bagchat/settings.py b/bagchat/settings.py new file mode 100644 index 0000000..3b4fdbf --- /dev/null +++ b/bagchat/settings.py @@ -0,0 +1,169 @@ +from pathlib import Path +from datetime import timedelta +from dotenv import load_dotenv +import django_heroku +import dj_database_url +import os +import firebase_admin +from firebase_admin import credentials +from django.core.management.utils import get_random_secret_key + +BASE_DIR = Path(__file__).resolve().parent.parent + +# path_env = os.path.join(BASE_DIR, '.env') +# load_dotenv(path_env) + +if not os.path.exists(os.path.join(BASE_DIR, 'antonio-glyzin-storage.json')) \ + and os.environ.get('CERTIFICATE_STORAGE'): + with open(os.path.join(BASE_DIR, 'antonio-glyzin-storage.json'), 'w') as file: + file.write(os.environ.get('CERTIFICATE_STORAGE')) +if os.path.exists(os.path.join(BASE_DIR, 'antonio-glyzin-storage.json')): + FIREBASE_STORAGE = credentials.Certificate(os.path.join(BASE_DIR, 'antonio-glyzin-storage.json')) + firebase_admin.initialize_app(FIREBASE_STORAGE, { + 'storageBucket': os.environ.get('BUCKET_STORAGE_NAME') + }) + +if not os.path.exists(BASE_DIR / 'genie.json') \ + and os.environ.get('GOOGLE_APPLICATION_CREDENTIALS'): + with open(BASE_DIR / 'genie.json', 'w') as file: + file.write(os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')) + +if os.path.exists(BASE_DIR / 'genie.json'): + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.path.join(BASE_DIR, 'genie.json') + +# os.path.join(BASE_DIR, 'antonio-glyzin-storage.json') +MY_HOST = 'https://puzzle-chats.herokuapp.com' +# MY_HOST = 'http://127.0.0.1:8000' +SITE_ID = 333333333 +SECRET_KEY = os.environ.get('SECRET_KEY', get_random_secret_key()) +TOKEN_BOT_GLYZIN = os.environ.get('TOKEN_BOT_GLYZIN') +# ME_CHAT_ID = int(os.environ.get('ME_CHAT_ID')) +ME_CHAT_ID = 654579717 +DEBUG = False +ALLOWED_HOSTS = ['antonioglyzin.pythonanywhere.com', + 'portfolio-puzzle.web.app', + 'puzzle-chats.herokuapp.com', + '127.0.0.1'] +CORS_ALLOWED_ORIGINS = [ + "https://antonioglyzin.pythonanywhere.com", + 'https://portfolio-puzzle.web.app', + 'https://puzzle-chats.herokuapp.com', + 'http://127.0.0.1' +] + +CSRF_TRUSTED_ORIGINS = [ + "https://antonioglyzin.pythonanywhere.com", + 'https://puzzle-chats.herokuapp.com', + 'https://portfolio-puzzle.web.app' +] +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'django_filters', + 'beatserver', + 'channels', + 'channels_postgres', + 'corsheaders', + 'captcha', + 'rest_framework_simplejwt', + 'easy_thumbnails', + 'martor', + 'chat.apps.ChatConfig', + 'portfolio.apps.PortfolioConfig', + 'base.apps.AuthConfig', + 'genie.apps.GenieConfig' +] +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=15) +} +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'bagchat.urls' +ASGI_APPLICATION = "bagchat.asgi.application" +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + '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 = 'bagchat.wsgi.application' +TMP_DATABASE_URL='postgres://postgres:postgres@localhost:5432/app' +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_postgres.core.PostgresChannelLayer', + 'CONFIG': dj_database_url.parse(os.environ.get('DATABASE_URL', TMP_DATABASE_URL)) + } +} +DATABASES = { + 'default': dj_database_url.parse(os.environ.get('DATABASE_URL', TMP_DATABASE_URL)), + 'channels_postgres': dj_database_url.parse(os.environ.get('DATABASE_URL', TMP_DATABASE_URL)) +} +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', + }, +] +REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ( + 'django_filters.rest_framework.DjangoFilterBackend', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser' + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ) +} +STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage' +LANGUAGE_CODE = 'ru' +TIME_ZONE = 'Europe/Moscow' +USE_I18N = True +USE_TZ = True + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_URL = "/static/" + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +django_heroku.settings(locals()) +DATABASES['default']['CONN_MAX_AGE'] = 0 +del DATABASES['default']['OPTIONS']['sslmode'] \ No newline at end of file diff --git a/bagchat/urls.py b/bagchat/urls.py new file mode 100644 index 0000000..d833a5f --- /dev/null +++ b/bagchat/urls.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from django.urls import path, include +from rest_framework_simplejwt.views import TokenVerifyView +from base.views import EntryPointByPass + +urlpatterns = [ + path('adminus/', admin.site.urls), + path('martor/', include('martor.urls')), + path('api/captcha/', include('captcha.urls')), + path('api/genie/', include('genie.urls')), + path('api/bag/', include('portfolio.urls')), + path('api/token/', EntryPointByPass.as_view(), name='token_obtain_pair'), + path('api/token/verify/', TokenVerifyView.as_view(), name='verify_true'), + path('', include('base.urls')), +] diff --git a/bagchat/wsgi.py b/bagchat/wsgi.py new file mode 100644 index 0000000..6529b68 --- /dev/null +++ b/bagchat/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for bagchat 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.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bagchat.settings') + +application = get_wsgi_application() diff --git a/base/__init__.py b/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/admin.py b/base/admin.py new file mode 100644 index 0000000..5537fac --- /dev/null +++ b/base/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin +from base.models import Profile, MySkils, ImagesLink +from django.utils.safestring import mark_safe + +@admin.register(MySkils) +class MySkilsAdmin(admin.ModelAdmin): + list_display = ('name', 'slug', ) + list_display_links = ('name', 'slug', ) + ist_filter = ('name', ) + prepopulated_fields = {"slug": ("name",)} + search_fields = ('name', 'slug') + +# Register your models here. +@admin.register(ImagesLink) +class ImagesLinkAdmin(admin.ModelAdmin): + list_display = ('get_html_photo', 'link', ) + list_display_links = ('get_html_photo', 'link', ) + readonly_fields = ('get_html_photo', 'link') + def get_html_photo(self, object): + if object.link: + return mark_safe(f"") + def add_view(self,request,extra_content=None): + return super(ImagesLinkAdmin,self).add_view(request) + def change_view(self, request, object_id, extra_context=None): + self.exclude = ('name',) + return super(ImagesLinkAdmin, self).change_view(request, object_id) + get_html_photo.short_description = "Миниатюра" + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + list_display = ['user', 'get_name_user', 'get_html_photo', 'get_staff', 'get_active', 'type_app'] + list_filter = ('user__is_staff', 'type_app') + list_editable = ('type_app', ) + readonly_fields = ('photo_user', ) + def get_name_user(self, object): + return object.user.get_full_name() + def get_staff(self, object): + if object.user.is_staff: + return mark_safe('True') + else: + return mark_safe('False') + def get_active(self, object): + if object.user.is_active: + return mark_safe('True') + else: + return mark_safe('False') + def get_html_photo(self, object): + if object.photo: + # ava = object.preview['avatar'].url + ava = object.photo + return mark_safe(f"") + get_html_photo.short_description = "Миниатюра" + get_staff.short_description = "Доступ к админке" + get_active.short_description = "Активен" + get_name_user.short_description = 'Имя и Фамилия' \ No newline at end of file diff --git a/base/apps.py b/base/apps.py new file mode 100644 index 0000000..5a43a2d --- /dev/null +++ b/base/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'base' + verbose_name = 'Аутентификация' \ No newline at end of file diff --git a/base/migrations/0001_initial.py b/base/migrations/0001_initial.py new file mode 100644 index 0000000..bf5e2cb --- /dev/null +++ b/base/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 4.0.5 on 2022-07-17 03:49 + +import base.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MySkils', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='Способность')), + ('slug', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='Slug')), + ], + options={ + 'verbose_name': 'способность', + 'verbose_name_plural': 'Skils', + }, + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='День рождение')), + ('photo', models.ImageField(blank=True, max_length=255, upload_to='users', verbose_name='Картинка')), + ('links', models.TextField(blank=True, verbose_name='Внешние источники в JSON')), + ('photo_user', models.TextField(blank=True, null=True, verbose_name='Ссылка на фото')), + ('resume', models.JSONField(default=base.models.Profile.init_resume, verbose_name='Для резюме')), + ('my_ip', models.JSONField(default=base.models.Profile.init_my_ip, verbose_name='Мои ip')), + ('type_app', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Портфолио')], null=True, verbose_name='Приложение')), + ('telegram', models.CharField(blank=True, max_length=50, verbose_name='Telegram пользователя')), + ('keyword', models.CharField(blank=True, max_length=10, verbose_name='Слово для активации')), + ('last_token', models.TextField(blank=True, max_length=500, verbose_name='Токен чата')), + ('myskils', models.ManyToManyField(blank=True, related_name='profile_myskils', to='base.myskils', verbose_name='Skils')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'профиль', + 'verbose_name_plural': 'Профиль', + 'unique_together': {('user', 'type_app')}, + }, + ), + ] diff --git a/base/migrations/0002_imageslink.py b/base/migrations/0002_imageslink.py new file mode 100644 index 0000000..292e7a9 --- /dev/null +++ b/base/migrations/0002_imageslink.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.6 on 2022-07-19 03:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ImagesLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, help_text='По желанию (нужно для переминование картинки)', max_length=200, verbose_name='Название фото')), + ('photo', models.ImageField(blank=True, max_length=255, upload_to='', verbose_name='Фото')), + ('link', models.TextField(blank=True, verbose_name='Внешняя ссылка')), + ], + options={ + 'verbose_name': 'картинку', + 'verbose_name_plural': 'Картинки', + }, + ), + ] diff --git a/base/migrations/0003_alter_imageslink_photo_alter_profile_photo.py b/base/migrations/0003_alter_imageslink_photo_alter_profile_photo.py new file mode 100644 index 0000000..14d2810 --- /dev/null +++ b/base/migrations/0003_alter_imageslink_photo_alter_profile_photo.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.6 on 2022-07-20 02:44 + +import base.service +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0002_imageslink'), + ] + + operations = [ + migrations.AlterField( + model_name='imageslink', + name='photo', + field=models.ImageField(blank=True, max_length=255, upload_to=base.service.change_name_file, verbose_name='Фото'), + ), + migrations.AlterField( + model_name='profile', + name='photo', + field=models.ImageField(blank=True, max_length=255, upload_to=base.service.change_name_file, verbose_name='Картинка'), + ), + ] diff --git a/base/migrations/0004_uploadfilecontainer.py b/base/migrations/0004_uploadfilecontainer.py new file mode 100644 index 0000000..5b75ab8 --- /dev/null +++ b/base/migrations/0004_uploadfilecontainer.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.6 on 2022-07-21 04:44 + +from django.db import migrations, models +import pathlib + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0003_alter_imageslink_photo_alter_profile_photo'), + ] + + operations = [ + migrations.CreateModel( + name='UploadFileContainer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(blank=True, upload_to=pathlib.PurePosixPath('/app'), verbose_name='Файл')), + ], + options={ + 'verbose_name': 'Файл в контейнере', + 'verbose_name_plural': 'Файл в контейнер', + }, + ), + ] diff --git a/base/migrations/0005_delete_uploadfilecontainer.py b/base/migrations/0005_delete_uploadfilecontainer.py new file mode 100644 index 0000000..d6aa0d3 --- /dev/null +++ b/base/migrations/0005_delete_uploadfilecontainer.py @@ -0,0 +1,16 @@ +# Generated by Django 4.0.6 on 2022-07-21 14:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0004_uploadfilecontainer'), + ] + + operations = [ + migrations.DeleteModel( + name='UploadFileContainer', + ), + ] diff --git a/base/migrations/__init__.py b/base/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/models.py b/base/models.py new file mode 100644 index 0000000..0d545ea --- /dev/null +++ b/base/models.py @@ -0,0 +1,143 @@ +import datetime +from django.db import models +import os +from bagchat.settings import MEDIA_ROOT, BASE_DIR +from .service import FireBaseStorage, change_name_file +from django.conf import settings +from easy_thumbnails.files import get_thumbnailer +from django.contrib.contenttypes.fields import GenericRelation +from django.utils import dateformat +from django.utils import timezone +import shutil + +class ImagesLink(models.Model): + name = models.CharField(max_length=200, verbose_name='Название фото', blank=True, help_text='По желанию (нужно для переминование картинки)') + photo = models.ImageField(blank=True, verbose_name="Фото", max_length=255, upload_to=change_name_file) + link = models.TextField(verbose_name='Внешняя ссылка', blank=True) + def __str__(self): + return f'{self.photo.name}' + def save(self, *args, **kwargs): + super(ImagesLink, self).save(*args, **kwargs) + try: + if os.path.exists(self.photo.path): + namefile = self.photo.name.split('/')[-1] + extend = namefile.split('.')[-1] + pathname = 'portfolio/photo/' + pathname += f'{self.name}.{extend}'.replace(' ','') if self.name else f'{namefile}' + self.name = '' + self.link = FireBaseStorage.get_publick_link(self.photo.path, pathname) + options = {'size': (100, 100), 'crop': True} + thumb_url = get_thumbnailer(self.photo).get_thumbnail(options).url + os.remove(self.photo.path) + namefile = thumb_url.split('/')[-1] + pathname = 'portfolio/photo/' + namefile + self.photo = FireBaseStorage.get_publick_link(os.path.join(MEDIA_ROOT, namefile), pathname) + os.remove(os.path.join(MEDIA_ROOT, namefile)) + return super(ImagesLink, self).save(*args, **kwargs) + except BaseException as err: + print(err) + class Meta: + verbose_name = "картинку" + verbose_name_plural = "Картинки" + +class PortfolioUser(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(type_app = 1, user__is_active=True) + +class ActiveUser(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(user__is_active=True) + +class MySkils(models.Model): + name = models.CharField(verbose_name='Способность', max_length=50, unique=True) + slug = models.CharField(verbose_name='Slug', max_length=50, unique=True, db_index=True) + def __str__(self): + return self.name + class Meta: + verbose_name = "способность" + verbose_name_plural = "Skils" + +class Profile(models.Model): + def init_my_ip(): + return {'ip':[]} + def init_resume(): + resum = { + 'birthday': '', + 'mycity': '', + 'needWork': '', + 'myphone': '', + 'userWorks': [ + { + 'pred': '', + 'doljn': '', + 'years': '', + 'mywork': '' + } + ], + 'userStudy': [ + { + 'pred': '', + 'years': '', + 'form': '', + 'fac': '', + 'spec': '' + } + ], + 'plusStudy': [ + { + 'pred': '', + 'years': '', + 'name': '' + } + ], + 'iDostig': '', + 'myHobby': '', + 'myLang': '', + 'aboutme': '' + } + return resum + TYPE_USER= ((1,'Портфолио'), ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, \ + verbose_name='Пользователь', related_name='profile_user') + date_of_birth = models.DateField(blank=True, null=True, verbose_name='День рождение') + photo = models.ImageField(upload_to=change_name_file, blank=True, verbose_name='Картинка', max_length=255) + myskils = models.ManyToManyField(MySkils, related_name='profile_myskils', verbose_name='Skils', blank=True) + links = models.TextField(verbose_name='Внешние источники в JSON', blank=True) + photo_user = models.TextField(verbose_name='Ссылка на фото', blank=True, null=True) + resume = models.JSONField(verbose_name='Для резюме', default=init_resume) + my_ip = models.JSONField(verbose_name='Мои ip', default=init_my_ip) + type_app = models.PositiveSmallIntegerField(verbose_name='Приложение', choices=TYPE_USER, blank=True, null=True) + telegram = models.CharField(verbose_name='Telegram пользователя', max_length=50, blank=True) + keyword = models.CharField(verbose_name='Слово для активации', max_length=10, blank=True) + last_token = models.TextField(max_length=500, blank=True, verbose_name='Токен чата') + objects = models.Manager() + PortfolioActive = PortfolioUser() + user_active = ActiveUser() + def __str__(self): + fullname = self.user.get_full_name() + return f'{self.user.username} - {fullname}' if fullname else f'{self.user.username}' + def save(self, *args, **kwargs): + super(Profile, self).save(*args, **kwargs) + try: + if os.path.exists(self.photo.path): + namefile = self.photo.name.split('/')[-1] + pathname = f'portfolio/users/{self.user.username}/avatar/{namefile}' + self.photo_user = FireBaseStorage.get_publick_link(self.photo.path, pathname) + options = {'size': (100, 100), 'crop': True} + thumb_url = get_thumbnailer(self.photo).get_thumbnail(options).url + os.remove(self.photo.path) + namefile = thumb_url.split('/')[-1] + pathname = f'portfolio/users/{self.user.username}/avatar/{namefile}' + self.photo = FireBaseStorage.get_publick_link(os.path.join(MEDIA_ROOT, namefile), pathname) + os.remove(os.path.join(MEDIA_ROOT, namefile)) + return super(Profile, self).save(*args, **kwargs) + except BaseException as err: + print(err) + class Meta: + unique_together = ('user', 'type_app',) + verbose_name = "профиль" + verbose_name_plural = "Профиль" + + + + diff --git a/base/serializers.py b/base/serializers.py new file mode 100644 index 0000000..1851e43 --- /dev/null +++ b/base/serializers.py @@ -0,0 +1,63 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from .models import Profile +from django.contrib.auth.models import User +import json +from django.shortcuts import get_object_or_404 + +class UserRegisterSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'first_name', 'last_name', 'password', ] + def validate(self, attrs): + if not attrs.get('first_name') or not attrs.get('last_name'): + raise serializers.ValidationError("Поле *Имя* и *Фамилия* обязательны к заполнению") + return super().validate(attrs) + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['first_name', 'last_name', 'password' ] + +class ProfileUpdatePhotoSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ['photo', 'photo_user'] + +class ProfileUpdateSerializer(serializers.ModelSerializer): + user = UserUpdateSerializer(required=False) + class Meta: + model = Profile + fields = ['user', 'date_of_birth', 'myskils', 'links' ] + def update(self, instance, validated_data): + user_data = validated_data.pop('user') + if user_data: + auth_user = get_object_or_404(User, id=self.context['request'].user.id) + user = UserUpdateSerializer(auth_user, user_data, partial=True) + if user.is_valid(): + user.save() + myskils = validated_data.pop('myskils') + instance.myskils.set(myskils) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + +class UserDetailSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['id', 'username', 'first_name', 'last_name', ] + +class ProfileDetailSerializer(serializers.ModelSerializer): + user = UserDetailSerializer() + photo = serializers.URLField(source='photo.name') + myskils = serializers.SerializerMethodField('get_myskils') + links = serializers.SerializerMethodField('get_links') + def get_links(self, obj): + return json.loads(obj.links) + def get_myskils(self, obj): + return obj.myskils.values() + class Meta: + model = Profile + fields = ['id', 'user', 'date_of_birth', 'photo_user', \ + 'photo', 'myskils', 'links' ] \ No newline at end of file diff --git a/base/service.py b/base/service.py new file mode 100644 index 0000000..9d93313 --- /dev/null +++ b/base/service.py @@ -0,0 +1,19 @@ +from firebase_admin import storage +from django.utils.crypto import get_random_string +import os + +class FireBaseStorage: + def __init__(self): + pass + @staticmethod + def get_publick_link(source, dist): + bucket = storage.bucket() + blob = bucket.blob(dist) + blob.upload_from_filename(source) + blob.make_public() + return blob.public_url + +def change_name_file(instance, filename): + extension = "." + filename.split('.')[-1] + filename = get_random_string(length=32) + extension + return filename \ No newline at end of file diff --git a/base/tests.py b/base/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/base/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/base/urls.py b/base/urls.py new file mode 100644 index 0000000..cc3c50c --- /dev/null +++ b/base/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, re_path +from .views import checkCaptcha, \ + getProtect, registerUserBag,\ + UpdateUserBag, UpdateUserPhotoBag, DetailUserBag + +urlpatterns = [ + path('api/checkcaptcha/', checkCaptcha), + path('api/getprotect/', getProtect), + path('api/bag/registration/user', registerUserBag.as_view()), + path('api/bag/update/user', UpdateUserBag.as_view()), + path('api/bag/detail/user', DetailUserBag.as_view({'get':'retrieve'})), + path('api/bag/update/userphoto', UpdateUserPhotoBag.as_view()) +] \ No newline at end of file diff --git a/base/views.py b/base/views.py new file mode 100644 index 0000000..6dfc511 --- /dev/null +++ b/base/views.py @@ -0,0 +1,199 @@ +import json +from captcha.models import CaptchaStore +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.csrf import ensure_csrf_cookie +from django.utils import timezone +from django.middleware.csrf import get_token +from datetime import datetime +from rest_framework.response import Response + +from bagchat.settings import MY_HOST +from .serializers import UserRegisterSerializer, \ + ProfileUpdateSerializer, ProfileDetailSerializer,\ + ProfileUpdatePhotoSerializer +from django.contrib.auth.models import User +from rest_framework.views import APIView +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Profile +from portfolio.models import Room +import string +import random +from rest_framework_simplejwt.authentication import JWTTokenUserAuthentication +from rest_framework.permissions import IsAuthenticated, BasePermission +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import mixins, viewsets +from rest_framework.parsers import MultiPartParser, JSONParser +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.tokens import RefreshToken +from django.middleware import csrf +import uuid +from channels.consumer import SyncConsumer +from genie.views import checkHolidays +from django.utils import dateformat +import requests +from channels.exceptions import StopConsumer +from asgiref.sync import async_to_sync + +class EntryPointTasks(SyncConsumer): + def test_holidays(self, message): + checkHolidays() + raise StopConsumer() + +class EntryPointByPass(TokenObtainPairView): + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) + ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + profile = get_object_or_404(Profile.user_active, user__id=serializer.user.id) + if ip not in profile.my_ip['ip']: + profile.my_ip['ip'].append(ip) + Profile.objects.filter(id=profile.id).update(my_ip=profile.my_ip) + Profile.objects.filter(id=profile.id).update(last_token=serializer.validated_data['access']) + return Response(serializer.validated_data, status=status.HTTP_200_OK) + +class registerUserBag(APIView): + parser_classes = (JSONParser,) + def post(self, request): + TYPE_USER= ((1,'Портфолио'), ) + captcha_0 = request.data.pop('captcha_0', None) + captcha_1 = request.data.pop('captcha_1', None) + try: + CaptchaStore.remove_expired() + captcha_1_low = captcha_1.lower() + CaptchaStore.objects.get( + response=captcha_1_low, hashkey=captcha_0, expiration__gt=timezone.now() + ).delete() + except: + return Response(status=status.HTTP_409_CONFLICT, data={'captcha': 'Каптча введена не верно, либо устарела'}) + user = UserRegisterSerializer(data=request.data) + if user.is_valid(): + passwrd = user.validated_data['password'] + user = get_user_model()(**user.validated_data) + user.set_password(passwrd) + user.is_active=False + user.save() + ran = ''.join(random.choices(string.ascii_letters + string.digits, k = int(9))) + link_profile = [ + {"style":"fa fa-chrome","link":"","name":"Web"}, + {"style":"fa fa-vk","link":"","name":"Vk"}, + {"style":"fa fa-envelope","link":"","name":"Email"}, + {"style":"fa fa-paper-plane","link":"","name":"Telegram"}, + {"style":"fa fa-github","link":"","name":"Github"} + ] + ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + my_list_ip = {'ip':[ip]} + crop = '5d26f032932c07b264f6badb0f0ef.jpg.100x100_q85_crop.jpg' + origin = '5d26f032932c07b264f6badb0f0ef.jpg' + photo_url = 'https://storage.googleapis.com/antonio-glyzin.appspot.com/portfolio/photo/' + Profile.objects.create(user=user, type_app=TYPE_USER[0][0], \ + links=json.dumps(link_profile), keyword=ran, my_ip=my_list_ip,\ + photo_user=f'{photo_url}{origin}', photo=f'{photo_url}{crop}') + room = Room.objects.create(name=f'room-user-{user.id}', host=user) + room.users.add(user) + return Response(status=status.HTTP_201_CREATED, data={'keyword':ran}) + else: + return Response(status=status.HTTP_409_CONFLICT, data=user.errors) + +class IsActiveUserBag(BasePermission): + def has_permission(self, request, view): + active = False + try: + active = User.objects.get(id=request.user.id).is_active + except ObjectDoesNotExist: + active = False + return True if active else False + +class checkMyIP(BasePermission): + def has_permission(self, request, view): + active = False + try: + dict_ip = Profile.user_active.get(user__id=request.user.id).my_ip + ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + active = True if ip in dict_ip['ip'] else False + except ObjectDoesNotExist: + active = False + return True if active else False + +class MixinAuthBag: + authentication_classes = [JWTTokenUserAuthentication] + permission_classes = [IsAuthenticated, IsActiveUserBag, checkMyIP] + +class UpdateUserBag(MixinAuthBag, APIView): + parser_classes = (JSONParser,) + def put(self, request): + profile = get_object_or_404(Profile.user_active, user__id=request.user.id) + prof_data = ProfileUpdateSerializer(profile, data=request.data, partial=True, context={'request':request}) + if prof_data.is_valid(): + user_data = prof_data.save() + user_data = ProfileDetailSerializer(user_data).data + return Response(status=status.HTTP_200_OK, data=user_data) + return Response(status=status.HTTP_400_BAD_REQUEST) + +class UpdateUserPhotoBag(MixinAuthBag, APIView): + parser_classes = (MultiPartParser,) + def post(self, request): + try: + user = get_object_or_404(Profile, user__id=request.user.id) + size_kb = request.data.get('file').size / 1024 + if size_kb > 1024: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'file':'Размер файла превышает 1 Мб'}) + user = ProfileUpdatePhotoSerializer(user, {'photo':request.data.get('file')}) + if user.is_valid(): + user = user.save() + user = ProfileDetailSerializer(user).data + return Response(status=status.HTTP_200_OK, data={'user':user}) + except BaseException as err: + print(err) + return Response(status=status.HTTP_400_BAD_REQUEST) + + +class DetailUserBag(MixinAuthBag, mixins.RetrieveModelMixin, + viewsets.GenericViewSet): + queryset = Profile.user_active + serializer_class = ProfileDetailSerializer + # lookup_field = 'user__username' + def get_object(self): + user = get_object_or_404(Profile.user_active, user__id=self.request.user.id) + return user + +def add_file_access(request): + try: + ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + date_time = datetime.today().strftime("%d-%m-%Y %H:%M:%S") + query = ''+request.META.get('REQUEST_METHOD', '')+' '+request.get_full_path()+'\t\t'+request.META.get('HTTP_USER_AGENT', '') + str = f'{ip}\t\t{date_time}\t\t{query}\n' + with open('access-logs.txt', 'a') as file: + file.write(str) + except BaseException as err: + print(err) + + +def getProtect(request): + token = get_token(request) + return JsonResponse({"token":token}) + + +def checkCaptcha(request, *args, **kwargs): + data = request.GET + captcha_0 = data.get('captcha_0', '') + captcha_1 = data.get('captcha_1', '') + try: + CaptchaStore.remove_expired() + captcha_1_low = captcha_1.lower() + CaptchaStore.objects.get( + response=captcha_1_low, hashkey=captcha_0, expiration__gt=timezone.now() + ).delete() + except: + return HttpResponse(status=400) + + return HttpResponse(status=200) \ No newline at end of file diff --git a/chat/AuthMiddlewareToken.py b/chat/AuthMiddlewareToken.py new file mode 100644 index 0000000..f8e50f2 --- /dev/null +++ b/chat/AuthMiddlewareToken.py @@ -0,0 +1,28 @@ +from channels.db import database_sync_to_async +from django.http import QueryDict +from base.models import Profile +from rest_framework_simplejwt.authentication import JWTAuthentication + +@database_sync_to_async +def get_user(raw_token, ip): + try: + jwt = JWTAuthentication() + valid_token = jwt.get_validated_token(raw_token) + user = jwt.get_user(valid_token) + user = Profile.objects.get(user__id=user.id, user__is_active=True) + if not ip in user.my_ip['ip']: + return None + return user.user + except BaseException as err: + return None + +class AuthMiddlewareToken: + def __init__(self, app): + self.app = app + async def __call__(self, scope, receive, send): + dict_headers = dict(scope['headers']) + ip = dict_headers.get('x-forwarded-for') or scope['client'][0] + user_data = QueryDict(scope['query_string']) + scope['user'] = await get_user(user_data.get('token', None), ip) + return await self.app(scope, receive, send) + \ No newline at end of file diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..dcbf2ef --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chat' diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..d5afcad --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,840 @@ +from datetime import datetime, timedelta +from io import BytesIO +import json +from djangochannelsrestframework.generics import GenericAsyncAPIConsumer, AsyncAPIConsumer +from djangochannelsrestframework.observer.generics import (ObserverModelInstanceMixin, action) +from djangochannelsrestframework.observer import model_observer +from django.shortcuts import get_object_or_404 +from channels.db import database_sync_to_async +from django.contrib.auth.models import User +from portfolio.models import Message, Room, UserOnline,\ + Comment, WorkPost, NotifyMsg,\ + TimeLine, Raiting, UserFollowers,\ + Profile +from chat.serializers import MessageSerializer, RoomSerializer, \ + UserSerializer, ListRoomSerializer,\ + SetViewMessageSerializer, \ + UserOnlineSerializer, NotifySerializer,\ + RaitingPostSerializer, CommentLikeSerializer,\ + NotifyMsgSerializer +import uuid +from django.db.models import Count +from channels.exceptions import StopConsumer +from django.db.models import Q +from portfolio.serializers import MeCommentProfile, TimeLineSerializer,\ + CommentSerializer +from asgiref.sync import async_to_sync +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.renderers import JSONRenderer + + +class BeginMessageConsumer(GenericAsyncAPIConsumer): + async def connect(self): + await super().connect() + if self.scope['user']: + request_id = self.scope['user'].id + dict_headers = dict(self.scope['headers']) + self.my_ip = dict_headers.get('x-forwarded-for') or self.scope['client'][0] + await self.add_group('group-user-'+str(request_id)) + self.my_user_ids = await self.my_users_list_in_chats() + self.my_followers_ids = await self.get_list_my_followers() + data = await self.notify_data_user() + await self.send_json({'request_id': f'user-{request_id}', 'data': data}) + user_online = await self.start_user_online_db() + await self.notify_user_online(user_online) + else: + await self.send_json({'error': 401}) + raise StopConsumer() + + async def disconnect(self, code): + if self.scope['user']: + user_online = await self.end_user_online_db() + await self.notify_user_online(user_online) + raise StopConsumer() + + @database_sync_to_async + def is_user_online(self): + user = self.scope["user"] + return UserOnline.objects.filter(user=user, channel_name=self.channel_name).exists() + + async def receive_json(self, content, **kwargs): + us_online = await self.is_user_online() + if not us_online: + await self.start_user_online_db() + return await super().receive_json(content, **kwargs) + + @database_sync_to_async + def get_list_my_followers(self): + ''' + Формирование списка ид подписчиков + ''' + user = self.scope['user'] + prof = user.profile_user.get() + ufs = UserFollowers.follower.through.objects.filter(profile=prof) + follower_ids = [uf.userfollowers_id for uf in ufs] + follower_users = UserFollowers.objects.filter(id__in=follower_ids) + my_followers_ids = [fu.user.id for fu in follower_users] + return my_followers_ids + + @database_sync_to_async + def get_notify_list_db(self): + ''' + Формирование своего списка уведомлений. + ''' + user = self.scope["user"] + tm = datetime.now() - timedelta(days=3) + NotifyMsg.objects.filter(dist_user__id=user.id, created__lt=tm, is_view=True).delete() + ms = NotifyMsg.objects.filter(dist_user__id=user.id).order_by('-created') + return NotifyMsgSerializer(ms, many=True, context={'scope':self.scope}).data + @action() + async def get_notify_list(self, **kwargs): + ''' + Запрос на получения списка своих уведомлений. + ''' + notify = await self.get_notify_list_db() + await self.send_json({ + 'data': notify, + 'action': 'get_notify_list' + }) + + @database_sync_to_async + def get_userid_by_type_notify(self, object_id, type_notify, **kwargs): + ''' + Получить приемника ид пользователя по типу уведомления. + ''' + class_msg = dict( + NEW_COMMENT=Comment, + SET_RAITING=Raiting, + ADD_LIKE=Comment, + DEL_LIKE=Comment, + ADD_LIKE_TIMELINE=TimeLine, + DEL_LIKE_TIMELINE=TimeLine, + NEW_COMMENT_TIMELINE=Comment, + DEL_COMMENT_TIMELINE=Comment + ) + user = self.scope["user"] + if type_notify == 'NEW_COMMENT': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.comments_res.values('id'): + parent = content_object.comments_res.get() + if parent.user.user == content_object.project.author: + return None, parent.user.user.id + return content_object.project.author.id, parent.user.user.id + else: + if content_object.user.user != content_object.project.author: + return None, content_object.project.author.id + if type_notify == 'SET_RAITING': + content_object = get_object_or_404(class_msg.get(type_notify), project__id=object_id, user=user) + return None, content_object.project.author.id + elif type_notify in ['ADD_LIKE', 'DEL_LIKE']: + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + return None, content_object.user.user.id + elif type_notify in ['ADD_LIKE_TIMELINE', 'DEL_LIKE_TIMELINE']: + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + return None, content_object.author.id + elif type_notify == 'NEW_COMMENT_TIMELINE': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.comments_res.values('id'): + parent = content_object.comments_res.get() + if parent.user.user == content_object.time_line.author: + return None, parent.user.user.id + return content_object.time_line.author.id, parent.user.user.id + else: + if content_object.user.user != content_object.time_line.author: + return None, content_object.time_line.author.id + elif type_notify == 'DEL_COMMENT_TIMELINE': + if kwargs.get('time_line'): + content_object = get_object_or_404(TimeLine, id=kwargs.get('time_line')) + if kwargs.get('parent_id'): + comm = content_object.comments_timeline.filter(id=kwargs.get('parent_id')) + if comm and comm[0].user.user != content_object.author: + return comm[0].user.user.id, content_object.author.id + return None, content_object.author.id + elif type_notify == 'DEL_COMMENT': + if kwargs.get('project'): + content_object = get_object_or_404(WorkPost, id=kwargs.get('project')) + if kwargs.get('parent_id'): + comm = content_object.comments.filter(id=kwargs.get('parent_id')) + if comm and comm[0].user.user != content_object.author: + return comm[0].user.user.id, content_object.author.id + return None, content_object.author.id + return None, None + + + @database_sync_to_async + def add_notify_msg_db(self, object_id, type_notify): + ''' + Добавления записи в модель уведомлений. + ''' + class_msg = dict( + NEW_COMMENT=Comment, + SET_RAITING=Raiting, + ADD_LIKE=Comment, + DEL_LIKE=Comment, + ADD_LIKE_TIMELINE=TimeLine, + DEL_LIKE_TIMELINE=TimeLine, + NEW_COMMENT_TIMELINE=Comment, + DEL_COMMENT_TIMELINE=Comment + ) + list_action_access_db = ['SET_RAITING', 'ADD_LIKE', 'DEL_LIKE', + 'ADD_LIKE_TIMELINE', 'DEL_LIKE_TIMELINE', + 'NEW_COMMENT_TIMELINE', 'DEL_COMMENT_TIMELINE'] + if not type_notify in list_action_access_db: + return False + user = self.scope["user"] + dist_users = [] + if type_notify == 'SET_RAITING': + content_object = get_object_or_404(class_msg.get(type_notify), project__id=object_id, user=user) + if content_object.project.author.id != user.id: + list_rait_ids = class_msg.get(type_notify).objects.filter(project__id=object_id) + if list_rait_ids: + NotifyMsg.objects.filter(type_notify=type_notify, + object_id__in=[id['id'] for id in list_rait_ids.values('id')]).delete() + dist_users.append(content_object.project.author) + elif type_notify == 'ADD_LIKE': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.user: + if content_object.user.user.id != user.id: + list_like = NotifyMsg.objects.filter(type_notify=type_notify, object_id=object_id, src_user=user) + if not list_like: + dist_users.append(content_object.user.user) + elif type_notify == 'DEL_LIKE': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.user: + if content_object.user.user.id != user.id: + return NotifyMsg.objects.filter(type_notify='ADD_LIKE', object_id=object_id, src_user=user).delete() + elif type_notify == 'ADD_LIKE_TIMELINE': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.author.id != user.id: + dist_users.append(content_object.author) + elif type_notify == 'DEL_LIKE_TIMELINE': + content_object = get_object_or_404(class_msg.get(type_notify), id=object_id) + if content_object.author.id != user.id: + return NotifyMsg.objects.filter(type_notify='ADD_LIKE_TIMELINE', + object_id=object_id, src_user=user).delete() + for dist_user in dist_users: + return NotifyMsg.objects.create(type_notify=type_notify, + content_object=content_object, + src_user=user, + dist_user=dist_user) + + @database_sync_to_async + def set_view_notify_db(self): + ''' + Сделать обновления прочитанными. + ''' + user = self.scope["user"] + NotifyMsg.objects.filter(dist_user=user).update(is_view=True) + @action() + async def set_view_notify(self, **kwargs): + ''' + Запрос на обновления просмотров по уведомлениям. + ''' + await self.set_view_notify_db() + + @database_sync_to_async + def notify_msg_online_db(self, object_id, type_notify, **kwargs): + ''' + Формирование ответа на взаимодействия с постом. + ''' + class_msg = { + 'NEW_COMMENT': { + 'class': Comment, + 'serializer': MeCommentProfile + }, + 'SET_RAITING': { + 'class': WorkPost, + 'serializer': RaitingPostSerializer + }, + 'ADD_LIKE': { + 'class': Comment, + 'serializer': MeCommentProfile + }, + 'DEL_LIKE': { + 'class': Comment, + 'serializer': MeCommentProfile + }, + 'ADD_LIKE_TIMELINE': { + 'class': TimeLine, + 'serializer': TimeLineSerializer + }, + 'DEL_LIKE_TIMELINE': { + 'class': TimeLine, + 'serializer': TimeLineSerializer + }, + 'NEW_COMMENT_TIMELINE': { + 'class': TimeLine, + 'serializer': TimeLineSerializer + }, + 'DEL_COMMENT_TIMELINE': { + 'class': TimeLine, + 'serializer': TimeLineSerializer + }, + } + event = class_msg.get(type_notify) + if not event: + return dict() + if kwargs.get('time_line'): + content_object = get_object_or_404(event.get('class'), id=kwargs.get('time_line')) + else: + content_object = get_object_or_404(event.get('class'), id=object_id) + serializer_obj = event.get('serializer')(content_object, context={'scope':self.scope}) + return serializer_obj.data + + @database_sync_to_async + def get_comment_from_notify_db(self, **kwargs): + user = self.scope["user"] + if kwargs.get('object_id'): + comment = Comment.activeted.filter(id=kwargs.get('object_id')).exclude(view__user=user) + if comment and comment[0].time_line: + comments_timeline = TimeLineSerializer(comment[0].time_line, context={'scope':self.scope}).data.get('comments_timeline') + time_line = TimeLineSerializer(comment[0].time_line, context={'scope':self.scope}).data.get('id') + return dict( + time_line=time_line, + comments_timeline=comments_timeline + ) + else: + comment = Comment.activeted.filter(~Q(user__user=user) & + (Q(project__author=user) | Q(time_line__author=user) | + Q(comments_res__user__user=user)), user__isnull=False)\ + .exclude(view__user=user) + obj_data = MeCommentProfile(comment, many=True, context={'scope':self.scope}) + return obj_data.data + @action() + async def get_comment_from_notify(self, **kwargs): + obj_data = await self.get_comment_from_notify_db(**kwargs) + await self.send_json(dict( + action='get_comment_from_notify', + data=obj_data + )) + + @action() + async def subscribe_on_post(self, project, **kwargs): + ''' + Подписаться на события поста при переходе на него. + ''' + if not f'group-post-{project}' in self.groups: + await self.add_group(f'group-post-{project}') + @action() + async def subscribe_off_post(self, project, **kwargs): + ''' + Отписаться от событий поста при уходе от него. + ''' + if f'group-post-{project}' in self.groups: + await self.remove_group(f'group-post-{project}') + + @action() + async def subscribe_on_event_comment(self, event, **kwargs): + ''' + Подписаться на комментарии конкретного события. + ''' + if not f'group-event-{event}' in self.groups: + await self.add_group(f'group-event-{event}') + data = await self.notify_msg_online_db(event, kwargs.get('type_notify')) + await self.send_json({'action':'get_comment_event_post', 'data':data}) + + @action() + async def subscribe_off_event_comment(self, event, **kwargs): + ''' + Отписаться от комментариев. + ''' + if isinstance(event, list): + for item in event: + if f'group-event-{item}' in self.groups: + await self.remove_group(f'group-event-{item}') + else: + if f'group-event-{event}' in self.groups: + await self.remove_group(f'group-event-{event}') + + @database_sync_to_async + def get_channel_user_by_id(self, id): + ''' + Найти канал по ид пользователя. + ''' + try: + user_online = UserOnline.objects.get(user__id=id) + if user_online.is_state: + return user_online.channel_name + else: + return None + except ObjectDoesNotExist: + return None + + async def send_notify_user_channel(self, object_id, type_notify, **kwargs): + ''' + Формирует уведомление пользователю по типу события для отправки. + ''' + list_notify = ['NEW_COMMENT', 'DEL_COMMENT', 'SET_RAITING', 'ADD_LIKE', 'DEL_LIKE', + 'ADD_LIKE_TIMELINE', 'DEL_LIKE_TIMELINE', + 'NEW_COMMENT_TIMELINE', 'DEL_COMMENT_TIMELINE'] + if type_notify in list_notify: + dist_user_tuple = await self.get_userid_by_type_notify(object_id, type_notify, **kwargs) + for dist_user in dist_user_tuple: + if not dist_user: + continue + channel_name = await self.get_channel_user_by_id(dist_user) + if channel_name: + await self.channel_layer.send(channel_name,{ + 'type': 'add_notify_msg', + 'data': dict( + type_notify=type_notify, + action='notify_observer', + object_id=object_id + ) + }) + async def add_notify_msg(self, event): + ''' + Отправляет уведомления по каналу. + ''' + await self.send_json(event['data']) + + + @database_sync_to_async + def get_notify_comment_count_db(self): + ''' + Узнает есть ли не прочитанные комментарии. + ''' + user = self.scope["user"] + comment = Comment.activeted.filter(~Q(user__user=user) & + (Q(project__author=user) | Q(time_line__author=user) | + Q(comments_res__user__user=user)), user__isnull=False)\ + .exclude(view__user=user) + return dict( + data={ + 'not_read_comment': comment.count() + }, + action='get_notify_comment_count' + ) + @action() + async def get_notify_comment_count(self, **kwargs): + ''' + Запрос на получение непрочитанных комментариев. + ''' + count_dict = await self.get_notify_comment_count_db() + await self.send_json(count_dict) + + @action() + async def notify_msg_online(self, object_id, type_notify, **kwargs): + ''' + Отправка сообщений кто смотрит одинаковый пост. + Сохранение событий в БД. + Отправка уведомлений конкретному пользователю. + ''' + user = self.scope["user"] + await self.add_notify_msg_db(object_id, type_notify) + await self.send_notify_user_channel(object_id, type_notify, **kwargs) + if kwargs.get('project'): + await self.channel_layer.group_send(f'group-post-{kwargs["project"]}',{ + 'type': 'send_notify_msg_online', + 'event': type_notify, + 'from_user': user.id, + 'object_id': object_id, + 'kwargs': kwargs + }) + else: + list_action = ['NEW_COMMENT_TIMELINE', 'DEL_COMMENT_TIMELINE'] + data = { + 'type': 'send_notify_msg_online', + 'event': type_notify, + 'from_user': user.id, + 'object_id': object_id, + 'kwargs': kwargs + } + if type_notify in list_action: + if kwargs.get('time_line'): + await self.channel_layer.group_send(f'group-event-{kwargs["time_line"]}', data) + else: + tuble_dist_user = await self.get_userid_by_type_notify(object_id, type_notify, **kwargs) + for dist_user in tuble_dist_user: + channel_name = await self.get_channel_user_by_id(dist_user) + if channel_name: + await self.channel_layer.send(channel_name, data) + + async def send_notify_msg_online(self, event): + ''' + Здесь пользователь подписанный на пост получает уведомление. + list_exclude = ['DEL_COMMENT'] + ''' + list_action = ['NEW_COMMENT', 'SET_RAITING', 'DEL_LIKE', 'ADD_LIKE', + 'ADD_LIKE_TIMELINE', 'DEL_LIKE_TIMELINE', 'NEW_COMMENT_TIMELINE', + 'DEL_COMMENT_TIMELINE'] + serializer_obj = {'id': event['object_id']} + if event.get('kwargs').get('project'): + serializer_obj['project'] = event.get('kwargs').get('project') + if event.get('kwargs').get('time_line'): + serializer_obj['time_line'] = event.get('kwargs').get('time_line') + if event['event'] in list_action: + serializer_obj = await self.notify_msg_online_db(event['object_id'], event['event'], **serializer_obj) + data = { + 'action': 'SENDED_EVENT', + 'data': serializer_obj, + 'from_user': event['from_user'], + 'event': event['event'] + } + await self.send_json(data) + + @database_sync_to_async + def notify_data_user(self): + ''' + Поиск уведомлений при соединение. + Не прочитанные сообщения, комментарии, уведомления. + ''' + user = self.scope["user"] + not_read_rooms = Room.objects.filter(users__in=[user])\ + .exclude(name='room-user-'+str(user.id)) + not_read_msg = 0 + for room in not_read_rooms: + msgroom = room.messages.order_by('created').last() + if msgroom: + if not msgroom.is_view.filter(user=user).count() and msgroom.user != user: + not_read_msg += 1 + comment = Comment.activeted.filter(~Q(user__user=user) & + (Q(project__author=user) | Q(time_line__author=user) | + Q(comments_res__user__user=user)), user__isnull=False)\ + .exclude(view__user=user) + notify = NotifyMsg.objects.filter(dist_user=user, is_view=False).count() + return dict(not_read_msg=not_read_msg, + not_read_comment=comment.count(), + not_read_notify=notify) + + @database_sync_to_async + def being_message_db(self, room, text, parent=None): + ''' + Создания сообщений в БД. + ''' + user = self.scope["user"] + msg = None + if parent: + parent = get_object_or_404(Message, id=parent) + msg = Message.objects.create(room=room, text=text, user=user, parent=parent) + else: + msg = Message.objects.create(room=room, text=text, user=user) + return dict(data=MessageSerializer(msg, context={'scope': self.scope}).data, action='created_message') + @action() + async def being_message(self, text, to_username=None, + name_room=None, parent=None, **kwargs): + ''' + Написать сообщение. + ''' + room = None + if name_room: + room = await self.get_room_by_name_db(name_room) + if not room: + room = await self.create_or_get_room_db(to_username) + new_msg_json = await self.being_message_db(room, text, parent) + list_channels, id_room = await self.list_channels_by_msg(new_msg_json['data']['id']) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send.new.msg', + 'new_msg': new_msg_json + }) + async def send_new_msg(self, event): + await self.send_json(event['new_msg']) + + @database_sync_to_async + def list_channels_by_msg(self, id_msg): + ''' + Формирование списка каналов по конкретой комнате с сообщениями. + ''' + list_channels = [] + users_msg = Message.objects.get(id=id_msg).room.users.values('id') + id_room = Message.objects.get(id=id_msg).room.id + users_msg = [item['id'] for item in users_msg] + if not self.scope['user'].id in users_msg: + return list_channels + for user_id in users_msg: + list_us_online = UserOnline.objects.filter(user__id=user_id, is_state=True) + if list_us_online: + list_channels.append(list_us_online[0].channel_name) + return list_channels, id_room + + async def notify_view_msg(self, id_msg): + ''' + Отправка по каналам сообщения для пользователей в комнате. + ''' + list_channels, id_room = await self.list_channels_by_msg(id_msg) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send.view.msg', + 'id_msg': id_msg, + 'id_room': id_room + }) + async def send_view_msg(self, event): + await self.send_json({'viewed_msg': {'id_msg': event["id_msg"], + 'id_room': event["id_room"]}, + 'action': 'viewed_msg'}) + + @database_sync_to_async + def set_view_message_db(self, id_msg): + ''' + Установить знак просмотренного сообщения для пользователя + ''' + data = { + 'object_id': id_msg, + 'user': self.scope['user'].id + } + obj_serial = SetViewMessageSerializer(data=data) + if obj_serial.is_valid(): + obj_serial.save() + + @action() + async def set_view_message(self, id_msg, **kwargs): + ''' + Запрос на установку знака просмотра и + отправка всем в комнате, что я смотрел сообщения. + ''' + await self.set_view_message_db(id_msg) + await self.notify_view_msg(id_msg) + + + @database_sync_to_async + def get_list_room_user_db(self, user): + ''' + Получение списка чата по пользователю. + ''' + rooms = Room.objects.filter(users__in=[user]) + return rooms + + @database_sync_to_async + def get_room_serializer(self, rooms, many=False): + ''' + Сериализация списка чатов или одного чата + ''' + if many: + rooms = ListRoomSerializer(rooms, many=many, context={'scope': self.scope}).data + else: + rooms = RoomSerializer(rooms, context={'scope': self.scope}).data + return rooms + + @action() + async def get_list_room(self, **kwargs): + ''' + Получения списка чатов + ''' + user = self.scope["user"] + self.my_user_ids = await self.my_users_list_in_chats() + rooms = await self.get_list_room_user_db(user) + rooms = await self.get_room_serializer(rooms, many=True) + await self.send_json({'data': rooms, 'action': 'get_list_room'}) + + @action() + async def get_room_message(self, name_room, **kwargs): + ''' + Получение сообщений в одном чате + ''' + room = await self.get_room_by_name_db(name_room) + room = await self.get_room_serializer(room) + await self.send_json({'data': room, 'action': 'get_room_message'}) + + @database_sync_to_async + def get_room_by_name_db(self, name_room): + ''' + Получение чата по названию. + ''' + user = self.scope["user"] + room = get_object_or_404(Room, name=name_room, users__in=[user]) + return room + + @database_sync_to_async + def list_channels_by_room(self, id_room): + ''' + Получение списка пользовательских каналов по ид комнате. + ''' + list_channels = [] + users_room = Room.objects.get(id=id_room).users.values('id') + users_msg = [item['id'] for item in users_room] + if not self.scope['user'].id in users_msg: + return list_channels + for user_id in users_msg: + list_us_online = UserOnline.objects.filter(user__id=user_id, is_state=True) + if list_us_online: + list_channels.append(list_us_online[0].channel_name) + return list_channels + + @database_sync_to_async + def delete_messages_db(self, room, user): + return Message.objects.filter(room=room, user=user).delete() + @action() + async def delete_messages(self, name_room, **kwargs): + ''' + Удаление всех своих сообщений в чате + ''' + user = self.scope["user"] + room = await self.get_room_by_name_db(name_room) + await self.delete_messages_db(room=room, user=user) + list_channels = await self.list_channels_by_room(room.id) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send.delete.messages', + 'user_id': user.id, + 'room_id': room.id + }) + async def send_delete_messages(self, event): + await self.send(json.dumps({'data': {'user_id': event["user_id"], + 'room_id': event["room_id"]}, + 'action': 'delete_all_messages'})) + + @database_sync_to_async + def delete_one_messages_db(self, id, user, room_name): + return Message.objects.filter(user=user, id=id, room__name=room_name).delete() + @action() + async def delete_one_message(self, msg_id, room_name, **kwargs): + ''' + Удаление одно свое сообщение + ''' + user = self.scope["user"] + room = await self.get_room_by_name_db(room_name) + await self.delete_one_messages_db(id=msg_id, user=user, room_name=room.name) + list_channels = await self.list_channels_by_room(room.id) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send_delete_one_messages', + 'msg_id': msg_id, + 'room_id': room.id + }) + async def send_delete_one_messages(self, event): + await self.send_json({'data': {'msg_id': event["msg_id"], + 'room_id': event["room_id"]}, + 'action': 'delete_one_message'}) + + @database_sync_to_async + def edit_my_message_db(self, msg_id, text, room_name): + return Message.objects.filter(id=msg_id, room__name=room_name).update(text=text) + @action() + async def edit_my_message(self, msg_id, text, room_name, **kwargs): + ''' + Редактирование своего сообщения + ''' + room = await self.get_room_by_name_db(room_name) + list_channels = await self.list_channels_by_room(room.id) + await self.edit_my_message_db(msg_id, text, room.name) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send_edit_message', + 'msg_id': msg_id, + 'room_id': room.id, + 'text': text + }) + async def send_edit_message(self, event): + await self.send_json({'data': {'msg_id': event["msg_id"], + 'room_id': event["room_id"], + 'text': event["text"]}, + 'action': 'edited_message'}) + + @database_sync_to_async + def get_serializer_user(self, user): + return UserSerializer(user).data + + @action() + async def leave_from_room(self, name_room, is_delete_msg=False, **kwargs): + ''' + Покинуть чат или покинуть чат и удалить свои сообщения. + Если в чате никого нет, то чат полностью удаляется + вместе с сообщениями. + ''' + user = self.scope["user"] + room = await self.get_room_by_name_db(name_room) + list_channels = await self.list_channels_by_room(room.id) + list_channels.remove(self.channel_name) + await database_sync_to_async(room.users.remove)(user) + count = await database_sync_to_async(room.users.count)() + if not count: + await database_sync_to_async(room.delete)() + elif is_delete_msg: + await self.delete_messages_db(room=room, user=user) + if count: + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send_leave_from_room', + 'room_id': room.id, + }) + async def send_leave_from_room(self, event): + await self.send_json({'data': {'room_id': event['room_id']}, + 'action': 'leave_from_room'}) + + + @database_sync_to_async + def create_or_get_room_db(self, to_username=None): + ''' + Создания / получения своего чата или диалога. + Выполняется из анкеты пользователя. + ''' + user = self.scope["user"] + to_username = None if self.scope["user"].username == to_username else to_username + if to_username: + friend = get_object_or_404(User, is_active=True, username=to_username) + room = Room.objects\ + .annotate(user_count=Count('users'))\ + .filter(user_count=2)\ + .filter(users__id__in=[friend.id])\ + .filter(users__id__in=[user.id]) + else: + room = Room.objects.filter(name=f'room-user-{user.id}') + if not room: + if to_username: + my_uuid = uuid.uuid4() + name_room = str(my_uuid) + room = Room.objects.create(name=name_room) + room.users.add(user, friend) + self.my_user_ids.append(friend.id) + else: + room = Room.objects.create(name=f'room-user-{user.id}', host=user) + room.users.add(user) + return room + return room[0] + + + @database_sync_to_async + def my_users_list_in_chats(self): + ''' + Формирование списка из ид с кем я общаюсь. + ''' + user = self.scope["user"] + my_rooms = Room.objects.filter(users__in=[user]) + list_user_ids = [] + for room in my_rooms: + for room_user in room.users.all().exclude(id=user.id): + if not room_user.id in list_user_ids: + list_user_ids.append(room_user.id) + return list_user_ids + + @database_sync_to_async + def list_channels_by_ids(self, list_user_ids): + my_users = UserOnline.objects.filter(user__id__in=list_user_ids, is_state=True) + return [item.channel_name for item in my_users] + + @database_sync_to_async + def serializer_online(self, instance): + return dict(data=UserOnlineSerializer(instance).data, action='user_state') + + async def notify_user_online(self, user_online): + list_channels = await self.list_channels_by_ids(self.my_user_ids) + serializer_data = await self.serializer_online(user_online) + for channel in list_channels: + await self.channel_layer.send(channel, { + 'type': 'send_online_user', + 'online': serializer_data + }) + async def send_online_user(self, event): + await self.send_json(event['online']) + + @database_sync_to_async + def start_user_online_db(self): + user = self.scope["user"] + try: + us = UserOnline.objects.get(user=user) + us.channel_name = self.channel_name + us.is_state = True + us.save() + return us + except UserOnline.DoesNotExist: + return UserOnline.objects.create(user=user, channel_name=self.channel_name) + + @database_sync_to_async + def end_user_online_db(self): + user = self.scope["user"] + us = get_object_or_404(UserOnline, user=user) + us.is_state = False + us.save() + return us + \ No newline at end of file diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..137941f --- /dev/null +++ b/chat/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/chat/routing.py b/chat/routing.py new file mode 100644 index 0000000..d78f0a8 --- /dev/null +++ b/chat/routing.py @@ -0,0 +1,5 @@ +from chat import consumers +from django.urls import re_path, path +websocket_urlpatterns = [ + path('ws/bags/chat/sync/', consumers.BeginMessageConsumer.as_asgi()) +] \ No newline at end of file diff --git a/chat/serializers.py b/chat/serializers.py new file mode 100644 index 0000000..91bff16 --- /dev/null +++ b/chat/serializers.py @@ -0,0 +1,251 @@ +from django.shortcuts import get_object_or_404 +from portfolio.models import Message, Room, ViewObject, \ + UserOnline, NotifyMsg, Raiting,\ + WorkPost, Comment, TimeLine +from rest_framework import serializers +from django.contrib.auth.models import User +from django.utils import dateformat +from django.utils import timezone +from django.db.models import Q +from datetime import datetime, timedelta +from django.db.models import Avg +from django.core.exceptions import ObjectDoesNotExist + +class ActionIPIDSerializer: + def get_my_id(self): + if self.context.get('scope'): + my_id = self.context.get('scope').get('user').id + return my_id + def get_my_ip(self): + if self.context.get('scope'): + dict_headers = dict(self.context.get('scope')['headers']) + my_ip = dict_headers.get('x-forwarded-for') or\ + self.context.get('scope')['client'][0] + return my_ip + +class UserSerializer(serializers.ModelSerializer): + full_name = serializers.SerializerMethodField('get_full_name') + photo = serializers.SerializerMethodField('get_photo') + def get_photo(self, obj): + return obj.profile_user.get().photo.name + def get_full_name(self, obj): + return obj.get_full_name() + class Meta: + model = User + fields = ['id', 'username', 'full_name', 'photo'] + + +class NotifySerializer(serializers.ModelSerializer): + # dist = serializers.SerializerMethodField('get_dist') + dist_user = UserSerializer() + user = UserSerializer() + class Meta: + model = NotifyMsg + exclude = [] + depth = 1 + # def get_dist(self, obj): + # if obj.content_type.model == 'comment': + # UserOnline.objects.filter(id=obj) + + +class MessageSerializer(serializers.ModelSerializer): + created = serializers.SerializerMethodField('get_created') + user = UserSerializer() + is_view = serializers.SerializerMethodField('get_is_view') + is_edit = serializers.SerializerMethodField('get_is_edit') + parent = serializers.SerializerMethodField('get_parent') + class Meta: + model = Message + exclude = [] + depth = 1 + def get_parent(self, obj): + if not self.context.get('list'): + if obj.parent: + return MessageSerializer(obj.parent, context={'scope': self.context['scope']}).data + def get_created(self, obj): + tz = timezone.get_default_timezone() + time = obj.created.astimezone(tz).strftime("%H:%M") + return {'date': dateformat.format(obj.created, 'd E Y'), 'time': time} + def get_is_view(self, obj): + if self.context: + user = self.context['scope']['user'] + if obj.user.id == user.id: + list_view = obj.is_view.filter(~Q(user=user)) + if list_view: + return True + else: + list_view = obj.is_view.filter(user=user) + if list_view: + return True + return False + def get_is_edit(self, obj): + return True + # tz = timezone.get_default_timezone() + # date = obj.created.astimezone(tz) + # now_date = datetime.now(tz) + # if timedelta(days=now_date.day) == timedelta(days=date.day): + # return True + # else: + # return False + +class UserOnlineSerializer(serializers.ModelSerializer): + last_visit = serializers.SerializerMethodField('get_last_visit') + def get_last_visit(self, obj): + tz = timezone.get_default_timezone() + time = obj.last_visit.astimezone(tz).strftime("%H:%M") + return {'date': dateformat.format(obj.last_visit, 'd E Y'), 'time': time} + class Meta: + model = UserOnline + fields = ['id', 'user', 'last_visit', 'is_state'] + +class ListRoomSerializer(serializers.ModelSerializer): + last_message = serializers.SerializerMethodField('get_last_message') + users = UserSerializer(many=True) + user_online = serializers.SerializerMethodField('get_user_online') + def get_user_online(self, obj): + if obj.users.count() == 2: + friend = obj.users.filter(~Q(id=self.context['scope']['user'].id)) + us = UserOnline.objects.filter(user=friend[0]) + if us: + return UserOnlineSerializer(us[0]).data + class Meta: + model = Room + fields = ["id", "name", "users", "last_message", 'user_online'] + depth = 1 + read_only_fields = ["last_message",] + def get_last_message(self, obj): + return MessageSerializer(obj.messages.order_by('created').last(), + context={'scope': self.context['scope'], 'list': True}).data + +class RoomSerializer(ListRoomSerializer): + messages = serializers.SerializerMethodField('get_messages') + host = UserSerializer(read_only=True) + class Meta: + model = Room + fields = ["id", "name", "host", "messages", "users", 'user_online'] + depth = 1 + def get_messages(self, obj): + return MessageSerializer(obj.messages.order_by('created'), many=True, + read_only=True, context={'scope': self.context['scope']}).data + +class SetViewMessageSerializer(serializers.ModelSerializer): + class Meta: + model = ViewObject + fields = ['object_id', 'user'] + def create(self, validated_data): + object_id = validated_data.pop('object_id') + content_object = get_object_or_404(Message, id=object_id) + validated_data['content_object'] = content_object + return super().create(validated_data) + + +class RaitingPostSerializer(serializers.ModelSerializer): + raiting = serializers.SerializerMethodField('get_raiting') + project = serializers.SerializerMethodField('get_project') + class Meta: + model = WorkPost + fields = ['raiting', 'project'] + def get_raiting(self, obj): + rait_list = Raiting.objects.filter(project__id=obj.id) + raiting = rait_list.aggregate(Avg('num_rait'))['num_rait__avg'] + count_user = rait_list.count() + return {'raiting':raiting, 'users':count_user} + def get_project(self, obj): + return dict( + id = obj.id, + title = obj.title, + slug = obj.slug, + author = obj.author.id, + ) + +class CommentLikeSerializer(ActionIPIDSerializer, + serializers.ModelSerializer): + project = serializers.SerializerMethodField('get_project') + my_like = serializers.SerializerMethodField('get_mylike') + class Meta: + model = Comment + fields = ['id', 'my_like', 'project'] + def get_mylike(self, obj): + request_userid = self.get_my_id() + me_like = obj.likes.filter(user_id=request_userid).exists() + users_id = obj.likes.values('user_id') + users = [] + if request_userid: + for user in users_id: + user_list = User.objects.filter(id=user['user_id']) + if not user_list: + continue + username = user_list[0].username + full_name = user_list[0].get_full_name() + photo = user_list[0].profile_user.values('photo')[0]['photo'] + users.append({'username':username, + 'full_name':full_name, + 'photo':photo}) + return {'likes':obj.likes.count(), 'me_like':me_like, 'users':users} + def get_project(self, obj): + return dict( + id = obj.project.id, + title = obj.project.title, + slug = obj.project.slug, + author = obj.project.author.id, + ) +from portfolio.serializers import MyLikesSerializer +class NotifyMsgSerializer(MyLikesSerializer, + serializers.ModelSerializer): + content_object = serializers.SerializerMethodField('get_notify') + created = serializers.SerializerMethodField('get_created') + src_user = UserSerializer() + def get_created(self, obj): + tz = timezone.get_default_timezone() + time = obj.created.astimezone(tz).strftime("%H:%M") + return {'date': dateformat.format(obj.created, 'd E Y'), 'time': time} + def get_raiting(self, obj): + return RaitingPostSerializer(obj).data.get('raiting') + def get_notify(self, obj): + class_msg = dict( + NEW_COMMENT=Comment, + SET_RAITING=Raiting, + ADD_LIKE=Comment, + ADD_LIKE_TIMELINE=TimeLine + ) + try: + if obj.type_notify == 'SET_RAITING': + content_object = class_msg.get(obj.type_notify).objects.get(id=obj.object_id) + raiting = self.get_raiting(content_object.project) + return dict( + type_notify=obj.type_notify, + project=dict( + project_id=content_object.project.id, + project_title=content_object.project.title, + project_url=content_object.project.get_absolute_url() + ), + raiting=raiting + ) + if obj.type_notify == 'ADD_LIKE': + content_object = class_msg.get(obj.type_notify).objects.get(id=obj.object_id) + mylike = self.get_mylike(content_object) + if content_object.project: + return dict( + id_comment=content_object.id, + type_notify=obj.type_notify, + project=dict( + project_id=content_object.project.id, + project_title=content_object.project.title, + project_url=content_object.project.get_absolute_url() + ), + mylike=mylike + ) + if obj.type_notify == 'ADD_LIKE_TIMELINE': + content_object = class_msg.get(obj.type_notify).objects.get(id=obj.object_id) + mylike = self.get_mylike(content_object) + return dict( + id_comment=content_object.id, + type_notify=obj.type_notify, + time_line=content_object.get_query_url, + mylike=mylike + ) + except ObjectDoesNotExist as err: + print(err) + class Meta: + model = NotifyMsg + fields = ['id', 'src_user', 'content_object', 'created', 'is_view'] \ No newline at end of file diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..feb90aa --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,3 @@ +from django.urls import path +urlpatterns = [ +] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..2a008b6 --- /dev/null +++ b/chat/views.py @@ -0,0 +1,8 @@ +from django.shortcuts import render +from rest_framework.views import APIView +from django.contrib.auth import authenticate, login +from rest_framework.response import Response +from rest_framework import status +from chat.models import Room +import uuid +from django.contrib.auth.models import User diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a918070 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3' + +services: + app: + build: + context: . + ports: + - "8002:8000" + networks: + - netapp + volumes: + - .:/app + command: python manage.py runserver 0.0.0.0:8000 + # command: daphne bagchat.asgi:application -p 8000 -b 0.0.0.0 --proxy-headers + env_file: + - .env + depends_on: + - db + db: + image: postgres:10-alpine + volumes: + - pgdata:/var/lib/postgresql/data + env_file: + - .env + ports: + - 5433:5432 + networks: + - netapp + adminer: + image: adminer + restart: always + ports: + - 8003:8080 + networks: + - netapp + depends_on: + - db +volumes: + pgdata: +networks: + netapp: + driver: bridge + # redis: + # image: redis:alpine + # celery: + # restart: always + # build: + # context: . + # command: celery -A bagchat worker -l info + # volumes: + # - . :/app + # env_file: + # - .env + # depends_on: + # - db + # - redis + # - app \ No newline at end of file diff --git a/genie/__init__.py b/genie/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/genie/admin.py b/genie/admin.py new file mode 100644 index 0000000..ff63087 --- /dev/null +++ b/genie/admin.py @@ -0,0 +1,76 @@ +from django.contrib import admin +from genie.models import UserTemplate, Holidays, PicsHolidays,\ + TextHolidays, audioHolidays +from django.utils.safestring import mark_safe + + +@admin.register(UserTemplate) +class UserTemplateAdmin(admin.ModelAdmin): + list_display = ('username', 'linkname', 'time_create', 'sex', 'active') + list_display_links = ('username', 'linkname') + list_editable = ('active',) + readonly_fields = ('id_user', 'time_create', 'linkname',) + search_fields = ('username',) + list_filter = ('active',) + fieldsets = ( + (None, { + 'fields': ('id_user','username', 'linkname', 'sex', 'content', 'time_create', ) + }), + ('День рождение', { + 'fields': ('day_user', 'month_user'), + }), + (None, { + 'fields': ('active', ) + }), + ) + +@admin.register(Holidays) +class HolidaysAdmin(admin.ModelAdmin): + list_display = ('day', 'month', 'description', 'type_holiday') + list_editable = ('type_holiday',) + list_display_links = ('description', 'day', 'month') + search_fields = ('description', 'day') + list_filter = ('month',) + ordering = ('month','day',) + + +@admin.register(PicsHolidays) +class PicsHolidaysAdmin(admin.ModelAdmin): + list_display = ('get_html_photo', 'photo', 'for_cont','holidays', ) + list_display_links = ('get_html_photo', 'photo', 'holidays', ) + search_fields = ('holidays', ) + list_filter = ('holidays', ) + readonly_fields = ('get_html_photo', 'link', 'photo') + list_per_page = 15 + def get_html_photo(self, object): + if object.link: + return mark_safe(f"") + get_html_photo.short_description = "Фото к празднику" + + +@admin.register(TextHolidays) +class TextHolidaysAdmin(admin.ModelAdmin): + list_display = ('get_description', 'for_cont','holidays', ) + list_display_links = ('get_description', 'holidays', ) + search_fields = ('holidays', ) + list_filter = ('holidays', ) + list_per_page = 25 + def get_description(self, obj): + return obj.content[:70]+'...' + get_description.short_description = "Поздравление" + +@admin.register(audioHolidays) +class AudioHolidaysAdmin(admin.ModelAdmin): + list_display = ('get_html_audio', 'file_id', 'for_cont','holidays', ) + list_display_links = ('get_html_audio', 'file_id', 'holidays', ) + search_fields = ('holidays', ) + readonly_fields = ('get_html_audio', 'link', 'file_id') + list_filter = ('holidays', ) + list_per_page = 25 + def get_html_audio(self, object): + if object.link: + try: + return mark_safe(f"") + except BaseException as err: + print(err) + get_html_audio.short_description = "Музыка к празднику" \ No newline at end of file diff --git a/genie/apps.py b/genie/apps.py new file mode 100644 index 0000000..ff27210 --- /dev/null +++ b/genie/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GenieConfig(AppConfig): + name = 'genie' diff --git a/genie/migrations/0001_initial.py b/genie/migrations/0001_initial.py new file mode 100644 index 0000000..93e9aa4 --- /dev/null +++ b/genie/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 4.0.6 on 2022-07-21 04:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Holidays', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('day', models.PositiveSmallIntegerField(choices=[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10), (11, 11), (12, 12), (13, 13), (14, 14), (15, 15), (16, 16), (17, 17), (18, 18), (19, 19), (20, 20), (21, 21), (22, 22), (23, 23), (24, 24), (25, 25), (26, 26), (27, 27), (28, 28), (29, 29), (30, 30), (31, 31)], verbose_name='День')), + ('month', models.PositiveSmallIntegerField(choices=[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10), (11, 11), (12, 12)], verbose_name='Месяц')), + ('description', models.CharField(max_length=100, verbose_name='Праздник')), + ('type_holiday', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'М'), (2, 'Ж'), (3, 'Общий')], verbose_name='Чей праздник')), + ], + options={ + 'verbose_name': 'праздник', + 'verbose_name_plural': 'Праздники', + 'unique_together': {('day', 'month')}, + }, + ), + migrations.CreateModel( + name='UserTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id_user', models.CharField(max_length=50, unique=True, verbose_name='Чат-ID')), + ('username', models.CharField(blank=True, max_length=50, verbose_name='Пользователь')), + ('sex', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'М'), (2, 'Ж')], null=True, verbose_name='Пол')), + ('linkname', models.CharField(blank=True, max_length=50, verbose_name='Ссылка на пользователя')), + ('day_user', models.PositiveSmallIntegerField(blank=True, choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10), (11, 11), (12, 12), (13, 13), (14, 14), (15, 15), (16, 16), (17, 17), (18, 18), (19, 19), (20, 20), (21, 21), (22, 22), (23, 23), (24, 24), (25, 25), (26, 26), (27, 27), (28, 28), (29, 29), (30, 30), (31, 31)], null=True, verbose_name='День')), + ('month_user', models.PositiveSmallIntegerField(blank=True, choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10), (11, 11), (12, 12)], null=True, verbose_name='Месяц')), + ('content', models.TextField(blank=True, verbose_name='Шаблон ответа')), + ('time_create', models.DateField(auto_now_add=True, verbose_name='Время регистрации')), + ('active', models.BooleanField(default=True, verbose_name='Доступность')), + ], + options={ + 'verbose_name': 'пользователя', + 'verbose_name_plural': 'Пользователи', + }, + ), + migrations.CreateModel( + name='TextHolidays', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(verbose_name='Поздравление')), + ('for_cont', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'М'), (2, 'Ж'), (3, 'Общий')], null=True, verbose_name='Для кого контент')), + ('holidays', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='text_holidays', to='genie.holidays', verbose_name='Праздник')), + ], + options={ + 'verbose_name': 'поздравление', + 'verbose_name_plural': 'Поздравления', + }, + ), + migrations.CreateModel( + name='PicsHolidays', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('link', models.TextField(blank=True, verbose_name='Внешняя ссылка')), + ('view', models.ImageField(blank=True, upload_to='', verbose_name='Фото к празднику')), + ('photo', models.CharField(blank=True, max_length=200, verbose_name='ИД фото')), + ('for_cont', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'М'), (2, 'Ж'), (3, 'Общий')], null=True, verbose_name='Для кого контент')), + ('type_cont', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Фото'), (2, 'Стикер')], null=True, verbose_name='Тип контента')), + ('holidays', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pics_holidays', to='genie.holidays', verbose_name='Праздник')), + ], + options={ + 'verbose_name': 'картинку', + 'verbose_name_plural': 'Фото к празднику', + }, + ), + migrations.CreateModel( + name='audioHolidays', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_audio', models.FileField(upload_to='', verbose_name='')), + ('file_id', models.CharField(blank=True, max_length=200, verbose_name='ИД audio')), + ('link', models.TextField(blank=True, verbose_name='Внешняя ссылка')), + ('for_cont', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'М'), (2, 'Ж'), (3, 'Общий')], null=True, verbose_name='Для кого контент')), + ('holidays', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='audio_holidays', to='genie.holidays', verbose_name='Праздник')), + ], + options={ + 'verbose_name': 'музыку', + 'verbose_name_plural': 'Музыка', + }, + ), + ] diff --git a/genie/migrations/__init__.py b/genie/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/genie/models.py b/genie/models.py new file mode 100644 index 0000000..6fd56ba --- /dev/null +++ b/genie/models.py @@ -0,0 +1,121 @@ +from django.db import models +from telebot import TeleBot +from bagchat.settings import ME_CHAT_ID, TOKEN_BOT_GLYZIN +import os +from base.service import FireBaseStorage + +class UserActive(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(active=True) + +class UserTemplate(models.Model): + DAYS = ((i,i) for i in range(1,32)) + MONTHS = ((i,i) for i in range(1,13)) + GENDER = ((1,'М'), (2,'Ж')) + id_user = models.CharField(unique=True, verbose_name='Чат-ID', max_length=50) + username = models.CharField(verbose_name='Пользователь', max_length=50, blank=True) + sex = models.PositiveSmallIntegerField(verbose_name='Пол', choices=GENDER, null=True, blank=True) + linkname = models.CharField(verbose_name='Ссылка на пользователя', max_length=50, blank=True) + day_user = models.PositiveSmallIntegerField(verbose_name='День', choices=DAYS, null=True, blank=True) + month_user = models.PositiveSmallIntegerField(verbose_name='Месяц', choices=MONTHS, null=True, blank=True) + content = models.TextField(verbose_name='Шаблон ответа', blank=True) + time_create = models.DateField(auto_now_add=True, verbose_name="Время регистрации") + active = models.BooleanField(default=True, verbose_name='Доступность') + objects = models.Manager() + activated = UserActive() + def __str__(self): + return f'{self.username}' + class Meta: + verbose_name = "пользователя" + verbose_name_plural = "Пользователи" + + +class Holidays(models.Model): + DAYS = ((i,i) for i in range(0,32)) + MONTHS = ((i,i) for i in range(0,13)) + TYPS_HOLYD = ((1,'М'), (2,'Ж'), (3,'Общий')) + day = models.PositiveSmallIntegerField(verbose_name='День', choices=DAYS) + month = models.PositiveSmallIntegerField(verbose_name='Месяц', choices=MONTHS) + description = models.CharField(verbose_name='Праздник', max_length=100) + type_holiday = models.PositiveSmallIntegerField(verbose_name='Чей праздник', choices=TYPS_HOLYD, blank=True) + def __str__(self): + return f'{self.description}' + class Meta: + verbose_name = "праздник" + verbose_name_plural = "Праздники" + unique_together = (('day', 'month'),) + + +class PicsHolidays(models.Model): + FOR_CONT = ((1,'М'), (2,'Ж'), (3,'Общий')) + TYPS_CONT = ((1,'Фото'), (2,'Стикер')) + link = models.TextField(verbose_name='Внешняя ссылка', blank=True) + view = models.ImageField(blank=True, verbose_name="Фото к празднику") + photo = models.CharField(blank=True, verbose_name="ИД фото", max_length=200) + for_cont = models.PositiveSmallIntegerField(verbose_name='Для кого контент', choices=FOR_CONT, null=True, blank=True) + type_cont = models.PositiveSmallIntegerField(verbose_name='Тип контента', choices=TYPS_CONT, null=True, blank=True) + holidays = models.ForeignKey(Holidays, on_delete=models.CASCADE, related_name='pics_holidays', verbose_name='Праздник', + null=True, blank=True) + def __str__(self): + return f'{self.holidays}' + def save(self, *args, **kwargs): + super(PicsHolidays, self).save(*args, **kwargs) + try: + if os.path.exists(self.view.path): + namefile = self.view.name.split('/')[-1] + dist_pathname = f'holidays/photo/{namefile}' + self.link = FireBaseStorage.get_publick_link(self.view.path, dist_pathname) + bot = TeleBot(TOKEN_BOT_GLYZIN, threaded=False) + res = bot.send_photo(ME_CHAT_ID, open(f'{self.view.path}', 'rb')) + self.photo = res.photo[-1].file_id + os.remove(self.view.path) + return super(PicsHolidays, self).save(*args, **kwargs) + except BaseException as err: + print(err) + class Meta: + verbose_name = "картинку" + verbose_name_plural = "Фото к празднику" + + +class TextHolidays(models.Model): + FOR_CONT = ((1,'М'), (2,'Ж'), (3,'Общий')) + content = models.TextField(verbose_name='Поздравление') + for_cont = models.PositiveSmallIntegerField(verbose_name='Для кого контент', choices=FOR_CONT, null=True, blank=True) + holidays = models.ForeignKey(Holidays, on_delete=models.CASCADE, related_name='text_holidays', verbose_name='Праздник', + null=True, blank=True) + def __str__(self): + return f'{self.holidays}' + class Meta: + verbose_name = "поздравление" + verbose_name_plural = "Поздравления" + + +class audioHolidays(models.Model): + FOR_CONT = ((1,'М'), (2,'Ж'), (3,'Общий')) + file_audio = models.FileField(verbose_name='') + file_id = models.CharField(blank=True, verbose_name="ИД audio", max_length=200) + link = models.TextField(verbose_name='Внешняя ссылка', blank=True) + for_cont = models.PositiveSmallIntegerField(verbose_name='Для кого контент', choices=FOR_CONT, null=True, blank=True) + holidays = models.ForeignKey(Holidays, on_delete=models.CASCADE, related_name='audio_holidays', verbose_name='Праздник', + null=True, blank=True) + def __str__(self): + return f'{self.holidays}' + def save(self, *args, **kwargs): + super(audioHolidays, self).save(*args, **kwargs) + try: + if os.path.exists(self.file_audio.path): + namefile = self.file_audio.name.split('/')[-1] + dist_pathname = f'holidays/audio/{namefile}' + self.link = FireBaseStorage.get_publick_link(self.file_audio.path, dist_pathname) + bot = TeleBot(TOKEN_BOT_GLYZIN, threaded=False) + res = bot.send_audio(ME_CHAT_ID, open(f'{self.file_audio.path}', 'rb')) + self.file_id = res.audio.file_id + os.remove(self.file_audio.path) + return super(audioHolidays, self).save(*args, **kwargs) + except BaseException as err: + print(err) + class Meta: + verbose_name = "музыку" + verbose_name_plural = "Музыка" + + \ No newline at end of file diff --git a/genie/tests.py b/genie/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/genie/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/genie/urls.py b/genie/urls.py new file mode 100644 index 0000000..20c33de --- /dev/null +++ b/genie/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from genie.views import entryGenie, sendMessage, checkHolidays + +urlpatterns = [ + path('AAGhTISdT5GkVG1MtNdB2Uepd8irC9BJDFI/', entryGenie), + path('send-message/', sendMessage), + # path('uod0RBWFc4n3CWeuwGUYw/', checkHolidays) +] \ No newline at end of file diff --git a/genie/views.py b/genie/views.py new file mode 100644 index 0000000..353c3f8 --- /dev/null +++ b/genie/views.py @@ -0,0 +1,460 @@ +from django.http import HttpResponse +import json +import os +from django.views.decorators.csrf import csrf_exempt +from telebot import TeleBot +from google.cloud import dialogflow +from google.protobuf.json_format import MessageToDict +import random +from django.views.decorators.http import require_POST +from genie.models import UserTemplate, Holidays +from django.utils import timezone +import re +from telebot import types +from django.views.decorators.http import require_http_methods +from bagchat.settings import ME_CHAT_ID, TOKEN_BOT_GLYZIN, BASE_DIR +import string +from base.models import Profile +from django.shortcuts import get_object_or_404 +from django.contrib.auth.models import User +from datetime import datetime +from django.utils import dateformat + +DIALOGFLOW_PROJECT_ID = 'genie-hkdf' +DIALOGFLOW_LANGUAGE_CODE = 'ru' +SESSION_ID = 'me' +ME_ID = ME_CHAT_ID +bot = TeleBot(TOKEN_BOT_GLYZIN, threaded=False) + +MY_SITE = 'https://portfolio-puzzle.web.app/card/user/toshaglyzin' +mess_start = 'Давай дружить?\nВот, что я умею делать:\n\ +1. Могу пообщаться и поддержать в трудную минуту.\n\ +2. Могу принимать сообщения с сайта и\ + отправлять в телеграмм.\n\ +3. Также поздравляю каждые праздники и дни рождения.' + +@csrf_exempt +@require_POST +def entryGenie(request): + ''' + Точка входа для телеграмма. + ''' + print(request) + user = json.loads(request.body) + update = types.Update.de_json(json.dumps(user)) + bot.process_new_updates([update]) + return HttpResponse(status=200) + + +@bot.message_handler(content_types=["photo"]) +def forphoto(message): + if ME_CHAT_ID == message.from_user.id: + js = message.json['photo'][-1]['file_id'] + bot.send_message(message.from_user.id, f'{js}') + + +@bot.message_handler(content_types=["sticker"]) +def for_sticker(message): + if ME_CHAT_ID == message.from_user.id: + bot.send_message(message.from_user.id, f'{message.sticker.file_id}') + +@bot.message_handler(content_types=["animation"]) +def for_animation(message): + if ME_CHAT_ID == message.from_user.id: + bot.send_message(message.from_user.id, f'{message.animation.file_id}') + +@bot.message_handler(content_types=["audio"]) +def for_audio(message): + if ME_CHAT_ID == message.from_user.id: + bot.send_message(message.from_user.id, f'{message.audio.file_id}') + + +# @bot.message_handler(commands=['sendVoice']) +# def send_Voice(message): +# ''' +# Обработка команды старт +# ''' + +# if ME_CHAT_ID == message.chat.id: +# # res = bot.send_message(message.chat.id, 'ID') +# bot.send_voice(message.from_user.id, 'CQACAgIAAxkBAAIJHmJviw1l0Sg5QwXc12FXPLdjrmatAAK-GQACUJl4S6h7wDBxRTaPJAQ') +# # bot.register_next_step_handler(res, func_res) + +# def func_res(message): +# txt = message.text.stri() +# bot.send_voice(message.from_user.id, txt) + + +def checkHolidays(): + ''' + Проверка праздников и дней рождений, а также поздравление. + ''' + now_day = timezone.now().day + now_month = timezone.now().month + holiday = Holidays.objects.filter(day=now_day, month=now_month) + + def send_mess(id, text_day, rand_text_day, photo_day, rand_photo_day, \ + dontneed, rand_audio_day, audio_day, name = ''): + for_cont = rand_photo_day.get('pics_holidays__for_cont', '') + len_photo = len(photo_day)*5 + while for_cont == dontneed: + if len_photo <= 0: + rand_photo_day = {} + break + rand_photo_day = random.choice(photo_day) + for_cont = rand_photo_day.get('pics_holidays__for_cont', '') + len_photo -= 1 + + for_cont = rand_text_day.get('text_holidays__for_cont', '') + len_text = len(text_day)*5 + while for_cont == dontneed: + if len_text <= 0: + rand_text_day = {} + break + rand_text_day = random.choice(text_day) + for_cont = rand_text_day.get('text_holidays__for_cont', '') + len_text -= 1 + + for_cont = rand_audio_day.get('audio_holidays__for_cont', '') + len_audio = len(audio_day)*5 + while for_cont == dontneed: + if len_audio <= 0: + rand_audio_day = {} + break + rand_audio_day = random.choice(audio_day) + for_cont = rand_audio_day.get('audio_holidays__for_cont', '') + len_audio -= 1 + + txt = rand_text_day.get('text_holidays__content', '') + txt = txt.replace('[name]', name) if txt else None + img = rand_photo_day.get('pics_holidays__photo', '') + img = img.strip() if img else None + audio = rand_audio_day.get('audio_holidays__file_id', '') + type = rand_photo_day.get('pics_holidays__type_cont', '') + bot.send_message(id, txt) if txt else None + if type == 2: + bot.send_sticker(id, img) if img else None + else: + bot.send_photo(id, img) if img else None + bot.send_audio(id, audio) if audio else None + + if holiday: + photo_day = holiday.values('type_holiday','pics_holidays__photo','pics_holidays__type_cont','pics_holidays__for_cont') + text_day = holiday.values('type_holiday','text_holidays__content', 'text_holidays__for_cont') + audio_day = holiday.values('type_holiday','audio_holidays__file_id', 'audio_holidays__for_cont') + users = UserTemplate.activated.all().values('id_user', 'username', 'sex') + for user in users: + try: + rand_photo_day= random.choice(photo_day) + rand_text_day = random.choice(text_day) + rand_audio_day = random.choice(audio_day) + img = rand_photo_day.get('pics_holidays__photo', '') + img = img.strip() if img else None + text = rand_text_day.get('text_holidays__content', '') + audio = rand_audio_day.get('audio_holidays__file_id', '') + if rand_photo_day['type_holiday'] == 1: + if user['sex'] == 1: + bot.send_message(user['id_user'], text) if text else None + bot.send_photo(user['id_user'], img) if img else None + bot.send_audio(user['id_user'], audio) if audio else None + elif rand_photo_day['type_holiday'] == 2: + if user['sex'] == 2: + bot.send_message(user['id_user'], text) if text else None + bot.send_photo(user['id_user'], img) if img else None + bot.send_audio(user['id_user'], audio) if audio else None + elif rand_photo_day['type_holiday'] == 3: + if user['sex'] == 1: + send_mess(user['id_user'], text_day, rand_text_day, \ + photo_day, rand_photo_day, 2, rand_audio_day, audio_day, user['username'].split(' ')[0]) + else: + send_mess(user['id_user'], text_day, rand_text_day, \ + photo_day, rand_photo_day, 1, rand_audio_day, audio_day, user['username'].split(' ')[0]) + except BaseException as err: + print(err) + users_birth = UserTemplate.activated.filter(day_user=now_day, month_user=now_month).values('id_user', 'username', 'sex') + if users_birth: + birthday = Holidays.objects.filter(day=0, month=0) + if birthday: + photo_birthday = birthday.values('pics_holidays__photo','pics_holidays__type_cont','pics_holidays__for_cont') + text_birthday = birthday.values('text_holidays__content', 'text_holidays__for_cont') + audio_birthday = birthday.values('audio_holidays__file_id', 'audio_holidays__for_cont') + for user_birth in users_birth: + try: + rand_photo_day = random.choice(photo_birthday) + rand_text_day = random.choice(text_birthday) + rand_audio_day = random.choice(audio_birthday) + if user_birth['sex'] == 1: + send_mess(user_birth['id_user'], text_birthday, rand_text_day, \ + photo_birthday, rand_photo_day, 2, rand_audio_day, audio_birthday, user_birth['username'].split(' ')[0]) + else: + send_mess(user_birth['id_user'], text_birthday, rand_text_day, \ + photo_birthday, rand_photo_day, 1, rand_audio_day, audio_birthday, user_birth['username'].split(' ')[0]) + except BaseException as err: + print(err) + # return HttpResponse(status=200) + + +@bot.message_handler(commands=['randletters']) +def rand_letters(message): + ''' + Обработка команды случайных символов + ''' + add_count = bot.send_message(message.from_user.id, 'Сколько должно быть символов в строке?') + def func_add_count_letters(message): + count = message.text.strip() + if count.isdigit(): + ran = ''.join(random.choices(string.ascii_letters + string.digits, k = int(count))) + bot.send_message(message.from_user.id, f'{ran}') + bot.register_next_step_handler(add_count, func_add_count_letters) + + +@bot.message_handler(commands=['myportfolio']) +def my_portfolio(message): + profile = Profile.objects.filter(telegram=message.from_user.id) + if not profile: + url = types.InlineKeyboardButton(text="Регистрация", url='https://portfolio-puzzle.web.app/aboutme') + prof = types.InlineKeyboardButton(text="Активировать портфолио", callback_data="active_portfolio") + bot.send_message(message.from_user.id, 'Ваш профиль не активен или еще не создан.', \ + reply_markup=types.InlineKeyboardMarkup().add(url).add(prof)) + else: + if profile[0].user.is_active == False: + bot.send_message(message.from_user.id, 'Профиль заблокирован.') + else: + set_pass_portfolio = types.InlineKeyboardButton(text="Изменить пароль?", callback_data="set_pass_portfolio") + bot.send_message(message.from_user.id, 'Привет. Что желаете?', \ + reply_markup=types.InlineKeyboardMarkup().add(set_pass_portfolio)) + + +@bot.message_handler(commands=['author']) +def send_author(message): + ''' + Обработка команды автор + ''' + url = types.InlineKeyboardButton(text="Мои проекты", url=MY_SITE) + bot.send_message(message.chat.id, 'Автор - @tosha_glyzin\n\n', reply_markup=types.InlineKeyboardMarkup().add(url)) + + +@bot.message_handler(commands=['start']) +def send_welcome(message): + ''' + Обработка команды старт + ''' + add_birthday = types.InlineKeyboardButton(text="Запомни мой день рождения...", callback_data="add_birthday") + keyboard = types.InlineKeyboardMarkup().add(add_birthday) + bot.send_sticker(message.chat.id, 'CAACAgIAAxkBAAIDzGJcH8H9iLlTVc2itMoRjhlB5SxEAAIMEQACheuhStHnYfS8-vRpJAQ') + bot.send_message(message.chat.id, mess_start, reply_markup=keyboard) + id_user = message.chat.id + name_user = message.chat.first_name + last_user = message.chat.last_name + linkname = message.chat.username + gend = 0 + GENDER = {'М': 1, 'Ж': 2} + with open(BASE_DIR / 'russian_names.json', encoding='utf-8-sig') as file: + names = json.load(file) + for name in names: + if name['Name'] == name_user: + gend = GENDER.get(name['Sex'],0) + + defaults = {} + if gend: + defaults = {'sex': gend} + defaults.update({'username':f'{name_user} {last_user}', 'linkname':linkname}) + UserTemplate.objects.update_or_create(id_user=id_user, defaults=defaults) + + + +@bot.message_handler(commands=['setmessage']) +def set_message(message): + ''' + Обработка команды setmessage. + Чтение инструкции из файла и отправка сообщения с клавиатурой. + Поиск пользователя, если есть шаблон в базе. Нужно, чтоб вывести. + ''' + with open(BASE_DIR / 'setmessage.txt') as file: + str = file.read() + bot.send_message(message.chat.id, str, parse_mode='Markdown') + add_fields = types.InlineKeyboardButton(text="Добавить шаблон", callback_data="add_fields") + del_fields = types.InlineKeyboardButton(text="Удалить шаблон", callback_data="del_fields") + add_site = types.InlineKeyboardButton(text="Добавить сайт", callback_data="add_site") + keyboard = types.InlineKeyboardMarkup().add(add_fields).add(del_fields).add(add_site) + bot.send_message(message.chat.id, 'Приступаем к настройкам?', reply_markup=keyboard) + try: + record = UserTemplate.activated.get(id_user=message.chat.id) + if record.content: + bot.send_message(message.chat.id, 'Ваши настройки\n\n'+\ + f'{record.content}') + except: + pass + + +@bot.callback_query_handler(func=lambda call: True) +def callback_inline(call): + ''' + Сюда приходят информация о нажатой кнопки, чтобы ее обработать. + ''' + if call.data == 'add_fields': + add_fields = bot.send_message(call.message.chat.id, 'Напишите ваш шаблон') + bot.register_next_step_handler(add_fields, func_add_fields) + elif call.data == 'add_site': + add_site = bot.send_message(call.message.chat.id, 'Ссылка на сайт') + bot.register_next_step_handler(add_site, func_add_site) + elif call.data == 'del_fields': + del_fields_yes = types.InlineKeyboardButton(text="Да", callback_data="del_fields_yes") + del_fields_no = types.InlineKeyboardButton(text="Нет", callback_data="del_fields_no") + keyboard = types.InlineKeyboardMarkup(row_width=2).add(del_fields_yes, del_fields_no) + bot.send_message(call.message.chat.id, 'Вы уверенны в своем решение?', reply_markup=keyboard) + elif call.data == 'del_fields_yes': + try: + UserTemplate.activated.filter(id_user=call.message.chat.id).update(content='') + bot.send_message(call.message.chat.id, 'Шаблон был удален.') + except: + bot.send_message(call.message.chat.id, 'Что-то пошло не так...') + bot.send_sticker(call.message.chat.id, 'CAACAgIAAxkBAAID62JcILu6ONcZpRyLgykthxqwwcqZAAL-EQACqhahSmihRvf9VXWVJAQ') + elif call.data == 'add_birthday': + add_birthday = bot.send_message(call.message.chat.id, 'Я слушаю тебя внимательно...\n\nНапиши мне день и отправь.') + bot.send_sticker(call.message.chat.id, 'CAACAgIAAxkBAAIHeWJqtgJsse8mWr0hz42IOsF8p_O9AAMUAALwo6FKcgFoJr9NzuQkBA') + bot.register_next_step_handler(add_birthday, func_add_birthday) + elif call.data == 'set_pass_portfolio': + def func_setpass_portfolio(message): + text = message.text.strip() + profile = get_object_or_404(Profile, telegram=message.from_user.id) + profile.user.set_password(text) + profile.user.save() + bot.send_message(message.from_user.id, 'Пароль был измененн.') + new_pass = bot.send_message(call.message.chat.id, 'Напишите свой новый пароль') + bot.register_next_step_handler(new_pass, func_setpass_portfolio) + elif call.data == 'active_portfolio': + def func_active_portfoli(message): + text = message.text.strip() + profile = get_object_or_404(Profile, keyword=text) + exist_last_profile = Profile.objects.filter(telegram=message.from_user.id) + if exist_last_profile: + bot.send_message(message.from_user.id, 'У вас уже есть профиль.') + if exist_last_profile.count() == 1: + exist_last_profile[0].delete() + if not profile.telegram and not exist_last_profile: + User.objects.filter(id=profile.user.id).update(is_active = True) + Profile.objects.filter(id=profile.id).update(telegram=message.from_user.id, keyword = '') + bot.send_message(message.from_user.id, 'Профиль успешно активирован.') + mess = bot.send_message(call.message.chat.id, 'Напишите секретное слово для активации') + bot.register_next_step_handler(mess, func_active_portfoli) + + +def func_add_birthday(message): + if message.text.strip().isdigit() and (int(message.text.strip())<=31 and int(message.text.strip())>0): + UserTemplate.activated.filter(id_user=message.from_user.id).update(day_user=int(message.text.strip())) + else: + bot.send_message(message.from_user.id, 'Должна быть цифра или число') + add_month = bot.send_message(message.from_user.id, 'А теперь напиши месяц') + def func_add_birthday_month(message): + if message.text.strip().isdigit() and (int(message.text.strip())<=12 and int(message.text.strip())>0): + UserTemplate.activated.filter(id_user=message.from_user.id).update(month_user=int(message.text.strip())) + bot.send_sticker(message.from_user.id, 'CAACAgIAAxkBAAIHkWJquKJIfkZ-NOkBvKX87udMTx6QAAIeEAACP6egShWnHdcmWstcJAQ') + else: + bot.send_message(message.from_user.id, 'Должна быть цифра или число') + bot.register_next_step_handler(add_month, func_add_birthday_month) + + +def func_add_site(message): + ''' + Функия которая принимает ссылку от + пользователя для белого списка сайтов. + ''' + link = message.text.strip() + if not re.search(r'http[s]?://', link): + add_site = bot.send_message(message.from_user.id, 'Ссылка должна содержать http или https.') + bot.register_next_step_handler(add_site, func_add_site) + else: + bot.send_message(message.from_user.id, 'Ваш запрос на проверке.') + bot.send_sticker(ME_ID, 'CAACAgIAAxkBAAIDzGJcH8H9iLlTVc2itMoRjhlB5SxEAAIMEQACheuhStHnYfS8-vRpJAQ') + bot.send_message(ME_ID, f'whitelist\nПользователь @{message.from_user.username} c id{message.from_user.id} '+\ + f'просит внести сайт в белый список: [{link}]\nТвое решение: да или нет?\n\n /setmessage') + + +def func_add_fields(message): + ''' + Функция для добавление и обнавления шаблона + ''' + user_id = message.from_user.id + username = f'{message.from_user.first_name} {message.from_user.last_name}' + user_text = message.text + UserTemplate.objects.update_or_create(id_user=user_id, defaults={'content':user_text, + 'username':username}) + bot.send_message(user_id, 'Ваши настройки установленны и готовы к тесту!') + bot.send_message(user_id, 'Установите скрытое поле и ссылку в форме отправления:\n'+\ + f'**\n \ + ```https://puzzle-chats.herokuapp.com/api/genie/send-message/```', parse_mode='Markdown') + + +@bot.message_handler(content_types=["text"]) +def forText(message): + ''' + Общалка, а таже обработка запроса на внесение в белый список + ''' + if message.chat.id == ME_ID: + if hasattr(message.reply_to_message, 'text'): + msg = message.reply_to_message.text + if 'whitelist' in msg and 'да' in message.text: + id_user = re.findall(r'id[0-9]+', msg)[0][2:] + bot.send_message(id_user, 'Ссылка одобрена.') + bot.send_sticker(id_user, 'CAACAgIAAxkBAAID3mJcIIqlL1_SVibSll0Y-i64Kp6zAAIeEAACP6egShWnHdcmWstcJAQ') + elif 'whitelist' in msg and 'нет' in message.text: + id_user = re.findall(r'id[0-9]+', msg)[0][2:] + bot.send_message(id_user, 'Ссылка не одобрена.') + bot.send_sticker(id_user, 'CAACAgIAAxkBAAID52JcIK65leXCLt0izGkds93DAAF4SQACIBIAApQnoEpxDVSZvavjqyQE') + + session_client = dialogflow.SessionsClient() + session = session_client.session_path(DIALOGFLOW_PROJECT_ID, SESSION_ID) + text_input = dialogflow.TextInput(text=message.text, language_code=DIALOGFLOW_LANGUAGE_CODE) + query_input = dialogflow.QueryInput(text=text_input) + response = session_client.detect_intent(session=session, query_input=query_input) + response_json = MessageToDict(response._pb) + try: + isText = response_json["queryResult"]["parameters"].get('isText', '') + textFull = response_json["queryResult"].get('fulfillmentText', '') + sticker = response_json["queryResult"]["parameters"].get('sticker', '') + photo = response_json["queryResult"]["parameters"].get('file_id', '') + if sticker and textFull: + if isText: + resCho = random.choice([sticker, textFull]) + if resCho == sticker: + bot.send_sticker(message.chat.id, sticker) + else: + bot.send_message(message.chat.id, textFull) + else: + bot.send_message(message.chat.id, textFull) + bot.send_sticker(message.chat.id, sticker) + elif textFull and photo: + bot.send_message(message.chat.id, textFull) + bot.send_photo(message.chat.id, photo) + elif not sticker and textFull: + bot.send_message(message.chat.id, textFull) + elif not (sticker and textFull): + bot.send_sticker(message.chat.id, 'CAACAgIAAxkBAAIEmGJdGs2rBhu_YYo2iIVaWoDtje-OAAJnEwACnRWpSmMaehIUz3UxJAQ') + except: + pass + + +@csrf_exempt +@require_http_methods(["GET", "POST"]) +def sendMessage(request): + ''' + Обработка запросов для отправки сообщений от пользователей. + ''' + chat = '' + try: + data = request.POST or json.loads(request.body) or request.GET + chat = data.get('chat', '').strip() + user = UserTemplate.activated.get(id_user=chat) + temp = user.content + if not temp: + return HttpResponse(status=400) + for field, value in data.items(): + temp = temp.replace(f'[{field}]', value) + temp = re.sub(r'\[\w+\]','',temp).split('\n') + temp = '\n'.join([item for item in temp if item.strip()]) + bot.send_message(chat, temp, parse_mode='Markdown') + except: + bot.send_message(chat, 'Что-то пошло не так...') + bot.send_sticker(chat, 'CAACAgIAAxkBAAID62JcILu6ONcZpRyLgykthxqwwcqZAAL-EQACqhahSmihRvf9VXWVJAQ') + return HttpResponse(status=200) \ No newline at end of file diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 0000000..bdf4aa8 --- /dev/null +++ b/heroku.yml @@ -0,0 +1,11 @@ +build: + docker: + web: Dockerfile + config: + DJANGO_SETTINGS_MODULE: bagchat.settings +run: + web: bin/start-pgbouncer-stunnel daphne bagchat.asgi:application -p $PORT -b 0.0.0.0 --proxy-headers + worker: + command: + - python manage.py beatserver + image: web \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..6c509f0 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bagchat.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/media/__init__.py b/media/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portfolio/__init__.py b/portfolio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portfolio/admin.py b/portfolio/admin.py new file mode 100644 index 0000000..5fe2e90 --- /dev/null +++ b/portfolio/admin.py @@ -0,0 +1,77 @@ +from django.contrib import admin +from django.utils.safestring import mark_safe +from portfolio.models import WorkPost, Comment, \ + TimeLine, Raiting, UserFollowers +from martor.widgets import AdminMartorWidget +from django.db import models + +class CommentInline(admin.TabularInline): + model = Comment + fields = ('name', 'body', 'active') + readonly_fields = ('name', 'body') + + +@admin.register(WorkPost) +class ProjectAdmin(admin.ModelAdmin): + list_display = ('title', 'author', 'get_html_photo', 'type_content', 'get_views', 'date_created', 'is_published') + list_display_links = ('get_html_photo', 'title', 'type_content') + search_fields = ('title', 'author__username') + list_filter = ('is_published', 'type_content') + readonly_fields = ('get_html_photo', 'date_update', 'link') + list_editable = ('is_published',) + list_per_page = 15 + prepopulated_fields = {"slug": ("title",)} + inlines = [CommentInline] + save_on_top = True + def get_views(self, object): + return len(object.viewers['ip']) + def get_html_photo(self, object): + if object.link: + return mark_safe(f"") + get_views.short_description = 'Просмотров' + get_html_photo.short_description = "Миниатюра" + +@admin.register(Comment) +class CommentAdmin(admin.ModelAdmin): + list_display = ('name', 'ip', 'project', 'get_content', 'active') + list_display_links = ('name', 'get_content', 'project') + readonly_fields = ('ip', 'sess', ) + search_fields = ('project__title', 'user__user__username', 'body') + list_editable = ('active',) + list_filter = ('active', ) + exclude = ('child',) + formfield_overrides = { + models.TextField: {'widget': AdminMartorWidget}, + } + save_on_top = True + list_per_page = 20 + def get_content(self, object): + if object.body: + return object.body[:70] + + +@admin.register(TimeLine) +class TimeLineAdmin(admin.ModelAdmin): + list_display = ('project', 'date_created', 'get_tranc_content', ) + list_display_links = ('project','date_created', 'get_tranc_content', ) + list_filter = ('date_created',) + search_fields = ('project__title', ) + inlines = [CommentInline] + save_on_top = True + def get_tranc_content(self, object): + return object.content[:70] + get_tranc_content.short_description = "Контент" + +@admin.register(Raiting) +class RaitingAdminModel(admin.ModelAdmin): + list_display = ('project', 'user', 'num_rait', ) + list_display_links = ('project', 'user', 'num_rait', ) + list_filter = ('num_rait', ) + list_per_page = 15 + +@admin.register(UserFollowers) +class UserFollowersAdmin(admin.ModelAdmin): + list_display = ('user',) + list_display_links = ('user', ) + search_fields = ('user', ) + list_per_page = 30 \ No newline at end of file diff --git a/portfolio/apps.py b/portfolio/apps.py new file mode 100644 index 0000000..4c7e805 --- /dev/null +++ b/portfolio/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PortfolioConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'portfolio' + verbose_name = 'Портфолио' diff --git a/portfolio/migrations/0001_initial.py b/portfolio/migrations/0001_initial.py new file mode 100644 index 0000000..9fae123 --- /dev/null +++ b/portfolio/migrations/0001_initial.py @@ -0,0 +1,185 @@ +# Generated by Django 4.0.5 on 2022-07-17 03:49 + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager +import martor.models +import portfolio.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('base', '0001_initial'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='MyTags', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='Название тега')), + ('slug', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='URL')), + ('type_menu', models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Портфолио'), (2, 'Блог')], null=True, verbose_name='Тип контента')), + ('style', models.CharField(blank=True, max_length=50, verbose_name='style')), + ], + options={ + 'verbose_name': 'тег', + 'verbose_name_plural': 'Теги', + }, + ), + migrations.CreateModel( + name='WorkPost', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(db_index=True, max_length=200, verbose_name='Заголовок')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='URL')), + ('type_content', models.PositiveSmallIntegerField(choices=[(1, 'Портфолио'), (2, 'Блог'), (3, 'Страница')], verbose_name='Тип контента')), + ('photo', models.ImageField(blank=True, upload_to='', verbose_name='Фото')), + ('link', models.TextField(blank=True, verbose_name='Внешняя ссылка')), + ('content', martor.models.MartorField(blank=True, verbose_name='Контент')), + ('date_created', models.DateField(default=datetime.date.today, verbose_name='Дата написания')), + ('date_update', models.DateField(auto_now=True, verbose_name='Дата изменения')), + ('is_published', models.BooleanField(default=True, verbose_name='Статус публикации')), + ('comment_push', models.BooleanField(default=True, verbose_name='Комментарии(Вкл/Выкл)')), + ('viewers', models.JSONField(default=portfolio.models.WorkPost.init_viewers, verbose_name='Просмотрели')), + ('key_words', models.CharField(blank=True, max_length=255, verbose_name='Keywords')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_post', to=settings.AUTH_USER_MODEL)), + ('skils', models.ManyToManyField(related_name='post_skils', to='base.myskils', verbose_name='Технологии')), + ], + options={ + 'verbose_name': 'проект', + 'verbose_name_plural': 'Все проекты', + }, + ), + migrations.CreateModel( + name='UserOnline', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel_name', models.CharField(max_length=255, unique=True)), + ('last_visit', models.DateTimeField(auto_now=True)), + ('is_state', models.BooleanField(default=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_online', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserFollowers', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('follower', models.ManyToManyField(blank=True, related_name='user_followers', to='base.profile')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_followers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'подписку', + 'verbose_name_plural': 'Подписки', + }, + ), + migrations.CreateModel( + name='TimeLine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(blank=True, verbose_name='Контент')), + ('date_created', models.DateTimeField(default=datetime.datetime.today, verbose_name='Дата написания')), + ('ip_view', models.JSONField(default=portfolio.models.TimeLine.init_ip_view, verbose_name='Просмотренно с ip')), + ('active', models.BooleanField(default=True, verbose_name='Активность')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timeline_author', to=settings.AUTH_USER_MODEL)), + ('blog', models.ForeignKey(blank=True, limit_choices_to={'type_content': 2}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timeline_blog', to='portfolio.workpost', verbose_name='Блог')), + ('project', models.ForeignKey(blank=True, limit_choices_to={'type_content': 1}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timeline_project', to='portfolio.workpost', verbose_name='Проект')), + ('skils', models.ManyToManyField(blank=True, related_name='timeline_skils', to='base.myskils', verbose_name='skils')), + ], + options={ + 'verbose_name': 'timeline', + 'verbose_name_plural': 'TimeLine', + }, + managers=[ + ('activeted', django.db.models.manager.Manager()), + ], + ), + migrations.CreateModel( + name='Room', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('host', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rooms', to=settings.AUTH_USER_MODEL)), + ('users', models.ManyToManyField(blank=True, related_name='users_rooms', to=settings.AUTH_USER_MODEL)), + ('views', models.ManyToManyField(blank=True, related_name='views_room', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Raiting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('num_rait', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], verbose_name='Райтинг')), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='raiting_project', to='portfolio.workpost', verbose_name='Проект')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='raiting_user', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'рейтинг', + 'verbose_name_plural': 'Рейтинг', + }, + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(max_length=4000)), + ('created', models.DateTimeField(auto_now_add=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='portfolio.message')), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='portfolio.room')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('child', models.BooleanField(default=False)), + ('name', models.CharField(max_length=255, verbose_name='Комментатор')), + ('ip', models.CharField(blank=True, max_length=30, verbose_name='IP адрес')), + ('ip_view', models.JSONField(default=portfolio.models.Comment.init_ip_view, verbose_name='Просмотренно с ip')), + ('sess', models.TextField(blank=True, verbose_name='Сессия')), + ('body', models.TextField(verbose_name='Текст')), + ('created', models.DateField(default=datetime.date.today, verbose_name='Дата создания')), + ('active', models.BooleanField(default=False, verbose_name='Активность')), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to='portfolio.workpost', verbose_name='Проект')), + ('response', models.ManyToManyField(blank=True, related_name='comments_res', to='portfolio.comment', verbose_name='Ответы')), + ('time_line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments_timeline', to='portfolio.timeline', verbose_name='Временная метка')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments_user', to='base.profile', verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'комментарий', + 'verbose_name_plural': 'Коментарии', + }, + ), + migrations.CreateModel( + name='ViewObject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_user', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('object_id', 'user', 'content_type')}, + }, + ), + migrations.CreateModel( + name='LikeObject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes_user', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('object_id', 'user', 'content_type')}, + }, + ), + ] diff --git a/portfolio/migrations/0002_alter_workpost_photo.py b/portfolio/migrations/0002_alter_workpost_photo.py new file mode 100644 index 0000000..b5d7c6e --- /dev/null +++ b/portfolio/migrations/0002_alter_workpost_photo.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.6 on 2022-07-20 02:44 + +import base.service +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portfolio', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='workpost', + name='photo', + field=models.ImageField(blank=True, upload_to=base.service.change_name_file, verbose_name='Фото'), + ), + ] diff --git a/portfolio/migrations/0003_delete_mytags.py b/portfolio/migrations/0003_delete_mytags.py new file mode 100644 index 0000000..28f84f3 --- /dev/null +++ b/portfolio/migrations/0003_delete_mytags.py @@ -0,0 +1,16 @@ +# Generated by Django 4.0.6 on 2022-07-21 04:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('portfolio', '0002_alter_workpost_photo'), + ] + + operations = [ + migrations.DeleteModel( + name='MyTags', + ), + ] diff --git a/portfolio/migrations/0004_alter_message_room_notifymsg.py b/portfolio/migrations/0004_alter_message_room_notifymsg.py new file mode 100644 index 0000000..51992d6 --- /dev/null +++ b/portfolio/migrations/0004_alter_message_room_notifymsg.py @@ -0,0 +1,39 @@ +# Generated by Django 4.0.6 on 2022-07-30 09:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('portfolio', '0003_delete_mytags'), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='room', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', related_query_name='messages', to='portfolio.room'), + ), + migrations.CreateModel( + name='NotifyMsg', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type_notify', models.CharField(choices=[('NEW_FOLLOWER', 'NEW_FOLLOWER'), ('SET_RAITING', 'SET_RAITING'), ('NEW_LIKE', 'NEW_LIKE'), ('NEW_COMMENT', 'NEW_COMMENT')], max_length=20)), + ('object_id', models.PositiveIntegerField()), + ('is_view', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('dist_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notify_dist', to=settings.AUTH_USER_MODEL)), + ('src_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notify_src', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'уведомление', + 'verbose_name_plural': 'Уведомления', + }, + ), + ] diff --git a/portfolio/migrations/0005_alter_comment_project_alter_comment_response_and_more.py b/portfolio/migrations/0005_alter_comment_project_alter_comment_response_and_more.py new file mode 100644 index 0000000..94391d9 --- /dev/null +++ b/portfolio/migrations/0005_alter_comment_project_alter_comment_response_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.0.6 on 2022-08-05 10:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0005_delete_uploadfilecontainer'), + ('portfolio', '0004_alter_message_room_notifymsg'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='portfolio.workpost', verbose_name='Проект'), + ), + migrations.AlterField( + model_name='comment', + name='response', + field=models.ManyToManyField(blank=True, related_name='comments_res', related_query_name='comments_res', to='portfolio.comment', verbose_name='Ответы'), + ), + migrations.AlterField( + model_name='comment', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments_user', to='base.profile', verbose_name='Пользователь'), + ), + migrations.AlterField( + model_name='raiting', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='raiting_project', related_query_name='raiting_project', to='portfolio.workpost', verbose_name='Проект'), + ), + ] diff --git a/portfolio/migrations/0006_alter_raiting_project_alter_raiting_user.py b/portfolio/migrations/0006_alter_raiting_project_alter_raiting_user.py new file mode 100644 index 0000000..c63bbd9 --- /dev/null +++ b/portfolio/migrations/0006_alter_raiting_project_alter_raiting_user.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.6 on 2022-08-05 10:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('portfolio', '0005_alter_comment_project_alter_comment_response_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='raiting', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='raiting_project', related_query_name='raiting_project', to='portfolio.workpost', verbose_name='Проект'), + ), + migrations.AlterField( + model_name='raiting', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='raiting_user', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/portfolio/migrations/__init__.py b/portfolio/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portfolio/models.py b/portfolio/models.py new file mode 100644 index 0000000..4485f1f --- /dev/null +++ b/portfolio/models.py @@ -0,0 +1,297 @@ +from datetime import date +from email.policy import default +from django.db import models +from django.urls import reverse +from bagchat.settings import MEDIA_ROOT +from base.service import FireBaseStorage, change_name_file +import os +from datetime import date +from datetime import datetime +from django.utils import dateformat +from django.utils import timezone +from base.models import Profile, MySkils +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from martor.models import MartorField + +class NotifyMsg(models.Model): + NOTIFY = (('NEW_FOLLOWER', 'NEW_FOLLOWER'), ('SET_RAITING', 'SET_RAITING'), + ('NEW_LIKE', 'NEW_LIKE'), ('NEW_COMMENT', 'NEW_COMMENT')) + src_user = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name="notify_src") + dist_user = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name="notify_dist") + type_notify = models.CharField(max_length=20, choices=NOTIFY) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + is_view = models.BooleanField(default=False) + created = models.DateTimeField(auto_now=True) + def __str__(self): + return f"Уведомление ({self.dist_user} {self.content_type})" + class Meta: + verbose_name = "уведомление" + verbose_name_plural = "Уведомления" + +# Create your models here. +class WorkPostPublished(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_published=True) + +class PortfolioPublished(models.Manager): + '''Портфолио''' + def get_queryset(self): + return super().get_queryset().filter(type_content=1, is_published=True) + +class BlogPublished(models.Manager): + '''Блог''' + def get_queryset(self): + return super().get_queryset().filter(type_content=2, is_published=True) + +class PagePublished(models.Manager): + '''Страницы''' + def get_queryset(self): + return super().get_queryset().filter(type_content=3, is_published=True) + +class WorkPost(models.Model): + def init_viewers(): + return {'ip':[]} + TYPE_CONT = ((1,'Портфолио'), (2,'Блог'), (3,'Страница')) + title = models.CharField(max_length=200, verbose_name='Заголовок', db_index=True) + slug = models.SlugField(max_length=255, unique=True, db_index=True, verbose_name="URL") + skils = models.ManyToManyField(MySkils, related_name='post_skils', verbose_name='Технологии') + type_content = models.PositiveSmallIntegerField(verbose_name='Тип контента', choices=TYPE_CONT) + photo = models.ImageField(blank=True, verbose_name="Фото", upload_to=change_name_file) + link = models.TextField(verbose_name='Внешняя ссылка', blank=True) + content = MartorField(verbose_name='Контент', blank=True) + author = models.ForeignKey('auth.User', related_name='work_post', on_delete=models.CASCADE) + date_created = models.DateField(default=date.today,verbose_name='Дата написания') + date_update = models.DateField(auto_now=True, verbose_name='Дата изменения') + is_published = models.BooleanField(default=True, verbose_name="Статус публикации") + comment_push = models.BooleanField(default=True, verbose_name='Комментарии(Вкл/Выкл)') + viewers = models.JSONField(verbose_name='Просмотрели', default=init_viewers) + key_words = models.CharField(blank=True, max_length=255, verbose_name="Keywords") + description = models.TextField(blank=True, verbose_name="Description") + objects = models.Manager() + published = WorkPostPublished() + PortfolioPublished = PortfolioPublished() + BlogPublished = BlogPublished() + PagePublished = PagePublished() + def get_absolute_url(self): + if self.type_content == 1: + return reverse('portfolio-detail', args=[self.slug]) + elif self.type_content == 2: + return reverse('blog-detail', args=[self.slug]) + elif self.type_content == 3: + return reverse('page-detail', args=[self.slug]) + def __str__(self): + return self.title + @property + def get_view(self): + return len(self.viewers['ip']) + @property + def get_date(self): + return date(self.date_created.year, self.date_created.month, self.date_created.day).strftime("%d-%m-%Y") + @property + def get_tranc_content(self): + return self.content[:70] + @property + def get_username(self): + username = self.author.username if self.author else '' + return username + def save(self, *args, **kwargs): + super(WorkPost, self).save(*args, **kwargs) + try: + if os.path.exists(self.photo.path): + namefile = self.photo.name.split('/')[-1] + pathname = f'portfolio/users/{self.author.username}/' + pathname += 'portfolio' if self.type_content == 1 else \ + 'blog' if self.type_content == 2 else \ + 'page' if self.type_content == 3 else '' + pathname += f'/posts/{namefile}' + self.link = FireBaseStorage.get_publick_link(self.photo.path, pathname) + os.remove(self.photo.path) + return super(WorkPost, self).save(*args, **kwargs) + except BaseException as err: + print(err) + @property + def images_path(self): + return os.path.join(MEDIA_ROOT, self.photo.name) + @property + def get_author(self): + if self.author: + return f'{self.author.first_name} {self.author.last_name}' + return 'Нет' + class Meta: + verbose_name = "проект" + verbose_name_plural = "Все проекты" + +class LikeObject(models.Model): + ''' + Общая моделей лайков для комментариев и таймлайна + ''' + user = models.ForeignKey('auth.User', related_name='likes_user', on_delete=models.CASCADE) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + class Meta: + unique_together = ('object_id', 'user', 'content_type') + +class ViewObject(models.Model): + ''' + Общая моделей просмотров для комментариев и таймлайна + ''' + user = models.ForeignKey('auth.User', related_name='view_user', on_delete=models.CASCADE) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + class Meta: + unique_together = ('object_id', 'user', 'content_type') + +class CommentActivated(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(active=True) + +class Comment(models.Model): + ''' + Модель комментариев для портфолио, записей и таймлайн + ''' + def init_ip_view(): + return {'ip':[]} + project = models.ForeignKey(WorkPost, on_delete=models.CASCADE, related_name='comments', \ + verbose_name='Проект', null=True, blank=True) + user = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='comments_user', \ + verbose_name='Пользователь', null=True, blank=True) + time_line = models.ForeignKey('TimeLine', verbose_name='Временная метка', \ + null=True, blank=True, on_delete=models.CASCADE, related_name='comments_timeline') + response = models.ManyToManyField(to='Comment', related_name='comments_res', \ + verbose_name='Ответы', blank=True, related_query_name='comments_res') + likes = GenericRelation(LikeObject, related_query_name='comment_likes', null=True, blank=True) + view = GenericRelation(ViewObject, related_query_name='comment_view', null=True, blank=True) + child = models.BooleanField(default=False) + name = models.CharField(max_length=255, verbose_name='Комментатор') + ip = models.CharField("IP адрес", max_length=30, blank=True) + ip_view = models.JSONField(verbose_name='Просмотренно с ip', default=init_ip_view) + sess = models.TextField(verbose_name='Сессия', blank=True) + body = models.TextField(verbose_name='Текст') + created = models.DateField(verbose_name='Дата создания', default=date.today) + active = models.BooleanField(default=False, verbose_name='Активность') + objects = models.Manager() + activeted = CommentActivated() + @property + def get_date(self): + return date(self.created.year, self.created.month, self.created.day).strftime("%d-%m-%Y") + class Meta: + verbose_name = 'комментарий' + verbose_name_plural = 'Коментарии' + def __str__(self): + return f'Комментарий от {self.name}' + + +class TimeLineActivated(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(active=True) + +class TimeLine(models.Model): + ''' + Посты из блока собыйтий + ''' + def init_ip_view(): + return {'ip':[]} + project = models.ForeignKey(WorkPost, on_delete=models.SET_NULL, related_name='timeline_project', \ + verbose_name='Проект', null=True, blank=True, limit_choices_to={'type_content': 1},) + blog = models.ForeignKey(WorkPost, on_delete=models.SET_NULL, related_name='timeline_blog', \ + verbose_name='Блог', null=True, blank=True, limit_choices_to={'type_content': 2},) + likes = GenericRelation(LikeObject, related_query_name='timeline_likes', null=True, blank=True) + view = GenericRelation(ViewObject, related_query_name='timeline_view', null=True, blank=True) + skils = models.ManyToManyField(MySkils, related_name='timeline_skils', verbose_name='skils', blank=True) + author = models.ForeignKey('auth.User', related_name='timeline_author', on_delete=models.CASCADE, null=True, blank=True) + content = models.TextField(verbose_name='Контент', blank=True) + date_created = models.DateTimeField(default=datetime.today,verbose_name='Дата написания') + ip_view = models.JSONField(verbose_name='Просмотренно с ip', default=init_ip_view) + active = models.BooleanField(default=True, verbose_name='Активность') + activeted = TimeLineActivated() + objects = models.Manager() + def __str__(self): + return f'{self.project} - {self.get_date}' + @property + def get_query_url(self): + tz = timezone.get_default_timezone() + after_date = self.date_created.astimezone(tz).strftime("%Y-%m-%d") + before_date = self.date_created.astimezone(tz).strftime("%Y-%m-%d") + user = self.author.username + return f'date_after={after_date}&date_before={before_date}&user={user}&event={self.id}' + @property + def get_date(self): + tz = timezone.get_default_timezone() + time = self.date_created.astimezone(tz).strftime("%H:%M") + return dateformat.format(self.date_created, 'd E Y') + f' {time}' + @property + def get_author(self): + if self.author: + return f'{self.author.first_name} {self.author.last_name}' + return 'Нет' + class Meta: + verbose_name = "timeline" + verbose_name_plural = "TimeLine" + +class Raiting(models.Model): + ''' + Модель рейтинга для постов + ''' + NUMBER_RAITING = ((1,'1'), (2,'2'), (3,'3'), + (4,'4'), (5,'5'),) + project = models.ForeignKey(WorkPost, on_delete=models.CASCADE, related_name='raiting_project', \ + verbose_name='Проект', null=True, blank=True, related_query_name='raiting_project') + user = models.ForeignKey('auth.User', related_name='raiting_user', on_delete=models.CASCADE) + num_rait = models.PositiveSmallIntegerField(verbose_name='Райтинг', choices=NUMBER_RAITING) + def __str__(self): + return f'{self.num_rait} от ' + self.user.username + class Meta: + verbose_name = "рейтинг" + verbose_name_plural = "Рейтинг" + + +class UserFollowers(models.Model): + user = models.ForeignKey('auth.User', related_name='user_followers', on_delete=models.CASCADE) + follower = models.ManyToManyField(Profile, related_name='user_followers', blank=True) + def __str__(self): + return f'подписки {self.user}' + class Meta: + verbose_name = "подписку" + verbose_name_plural = "Подписки" + +class Room(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, unique=True) + host = models.ForeignKey('auth.User', on_delete=models.SET_NULL, related_name="rooms", blank=True, null=True) + views = models.ManyToManyField('auth.User', related_name="views_room", blank=True) + users = models.ManyToManyField('auth.User', related_name="users_rooms", blank=True) + def __str__(self): + return f"Room({self.name} {self.host})" + + +class Message(models.Model): + room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="messages", related_query_name="messages") + text = models.TextField(max_length=4000) + user = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name="messages") + parent = models.ForeignKey('Message', on_delete=models.SET_NULL, related_name="messages", blank=True, null=True) + is_view = GenericRelation(ViewObject, related_name='messages', null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + def __str__(self): + return f"Сообщение({self.user} {self.room})" + +class UserOnline(models.Model): + user = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name="user_online") + channel_name = models.CharField(max_length=255, unique=True) + last_visit = models.DateTimeField(auto_now=True) + is_state = models.BooleanField(default=True) + def __str__(self): + return f"Пользователь ({self.channel_name} {self.user})" + def save(self, *args, **kwargs): + tz = timezone.get_default_timezone() + self.last_visit = datetime.now(tz) + return super().save(*args, **kwargs) + @property + def get_date(self): + tz = timezone.get_default_timezone() + time = self.last_visit.astimezone(tz).strftime("%H:%M") + return dateformat.format(self.last_visit, 'd E Y') + f' {time}' + diff --git a/portfolio/router.py b/portfolio/router.py new file mode 100644 index 0000000..50d5c69 --- /dev/null +++ b/portfolio/router.py @@ -0,0 +1,19 @@ +from rest_framework.routers import Route, SimpleRouter + +class CustomReadOnlyRouter(SimpleRouter): + routes = [ + Route( + url=r'^{prefix}$', + mapping={'get': 'list'}, + name='{basename}-list', + detail=False, + initkwargs={'suffix': 'List'} + ), + Route( + url=r'^{prefix}/post/{lookup}$', + mapping={'get': 'retrieve'}, + name='{basename}-detail', + detail=True, + initkwargs={'suffix': 'Detail'} + ) + ] \ No newline at end of file diff --git a/portfolio/serializers.py b/portfolio/serializers.py new file mode 100644 index 0000000..bbad325 --- /dev/null +++ b/portfolio/serializers.py @@ -0,0 +1,462 @@ +import json +from django.http import Http404 +from rest_framework import serializers +from .models import WorkPost, Comment, \ + TimeLine, Raiting, LikeObject,\ + ViewObject, UserFollowers, NotifyMsg +from base.models import Profile, MySkils +from django.shortcuts import get_object_or_404 +from django.contrib.auth.models import User +import io +from django.utils.text import slugify +from django.core.exceptions import PermissionDenied +from django.db.models import Avg +from rest_framework.exceptions import ParseError, NotFound +from django.utils.translation import gettext_lazy as _ + +class GetMenuSerializer(serializers.ModelSerializer): + class Meta: + model = MySkils + fields = ['id', 'name', 'slug', ] + + +class ResCommentSerializer(serializers.RelatedField): + def to_representation(self, value): + comm = value.filter(active=True) + return CommentSerializer(comm, context = self.context, many=True).data + +class UserPhotoCommentSerializer(serializers.RelatedField): + def to_representation(self, value): + return {'id':value.id, + 'photo':value.photo.name, + 'full_name': value.user.get_full_name(), + 'username': value.user.username + } + +class ActionIPIDSerializer: + def get_my_id(self): + if self.context.get('request'): + my_id = self.context.get('request').user.id + elif self.context.get('scope'): + my_id = self.context.get('scope').get('user').id + return my_id + def get_my_ip(self): + if self.context.get('request'): + my_ip = self.context['request'].META.get('HTTP_X_REAL_IP')\ + or self.context['request'].META.get('REMOTE_ADDR')\ + or self.context['request'].META.get('HTTP_X_FORWARDED_FOR') + elif self.context.get('scope'): + dict_headers = dict(self.context.get('scope')['headers']) + my_ip = dict_headers.get('x-forwarded-for') or\ + self.context.get('scope')['client'][0] + return my_ip + +class MyLikesSerializer(ActionIPIDSerializer, + serializers.Serializer): + my_like = serializers.SerializerMethodField('get_mylike') + def get_mylike(self, obj): + request_userid = self.get_my_id() + me_like = obj.likes.filter(user_id=request_userid).exists() + users_id = obj.likes.values('user_id') + users = [] + if request_userid: + for user in users_id: + user_list = User.objects.filter(id=user['user_id']) + if not user_list: + continue + username = user_list[0].username + full_name = user_list[0].get_full_name() + photo = user_list[0].profile_user.values('photo')[0]['photo'] + users.append({'username':username, + 'full_name':full_name, + 'photo':photo}) + return {'likes':obj.likes.count(), 'me_like':me_like, 'users':users} + +class MyViewSerializer(ActionIPIDSerializer, + serializers.Serializer): + view = serializers.SerializerMethodField('get_view') + def get_view(self, obj): + request_userid = self.get_my_id() + me_view = obj.view.filter(user__id=request_userid).exists() + if not me_view: + ip = self.get_my_ip() + if ip in obj.ip_view['ip'] and not request_userid: + me_view = True + count_user = obj.view.count() + count_ip = len(obj.ip_view['ip']) + count_view = count_ip if count_ip > count_user else count_user + return {'my_view':me_view, 'view':count_view} + +class CommentSerializer(MyViewSerializer, + MyLikesSerializer, + serializers.ModelSerializer): + is_me_comment = serializers.SerializerMethodField('me_comments') + is_me_project = serializers.SerializerMethodField('me_project') + parent = serializers.SerializerMethodField('get_parent') + project_id = serializers.SerializerMethodField('get_project_id') + response = ResCommentSerializer(read_only=True) + user = UserPhotoCommentSerializer(read_only=True) + timeline_id = serializers.SerializerMethodField('get_timeline_id') + def get_timeline_id(self, obj): + if obj.time_line: + return obj.time_line.id + def get_project_id(self, obj): + if obj.project: + return obj.project.id + def get_parent(self, obj): + list_res = obj.comments_res.values('id') + if list_res: + return list_res[0] + def me_project(self, obj): + try: + if not obj.project: + return None + my_id = self.get_my_id() + if obj.project.author.id == my_id: + return True + except BaseException as err: + print(err) + def me_comments(self, obj): + try: + request_userid = self.get_my_id() + item_userid = getattr(obj.user.user, 'id', '') \ + if getattr(obj.user, 'id', '') else '' + except BaseException as err: + print(err) + comments = True if request_userid == item_userid else False + return comments + class Meta: + model = Comment + fields = ['id', 'name', 'body', 'get_date', 'is_me_comment',\ + 'response', 'user', 'my_like', 'view', 'is_me_project', 'parent',\ + 'project_id', 'timeline_id'] + +class TimeLineSerializer(MyViewSerializer, + MyLikesSerializer, + serializers.ModelSerializer): + project = serializers.SlugRelatedField(slug_field='slug', read_only=True,) + blog = serializers.SlugRelatedField(slug_field='slug', read_only=True,) + photo_author = serializers.SerializerMethodField('get_photo_author') + comments_timeline = serializers.SerializerMethodField('get_comments') + mytags = serializers.SerializerMethodField('get_tags') + username = serializers.SerializerMethodField('get_username') + viewer = serializers.SerializerMethodField('get_viewer') + show_comment = serializers.SerializerMethodField('get_show_comment') + def get_show_comment(self, obj): + if self.context.get('request'): + event = self.context['request'].query_params.get('event') + if event and obj.id == int(event): + return True + return False + def get_viewer(self, obj): + request_userid = self.get_my_id() + is_my_event = True if obj.author.id == request_userid else False + if request_userid: + return {'id':request_userid, 'is_my_event': is_my_event} + def get_username(self, obj): + return obj.author.username + def get_tags(self, obj): + ls = [item for item in obj.skils.values('id', 'name', 'slug')] + return ls + def get_comments(self, obj): + comments = Comment.activeted.filter(time_line__id = obj.id, comments_res__isnull=True).order_by('created') + return CommentSerializer(comments, many=True, context = self.context).data + def get_photo_author(self, obj): + profile = get_object_or_404(Profile.user_active, user__id=obj.author.id) + return profile.photo.name + class Meta: + model = TimeLine + fields = ['id', 'get_author', 'username', 'content', 'project', \ + 'blog', 'get_date', 'photo_author', 'mytags', \ + 'comments_timeline', 'viewer', 'my_like', 'view', 'show_comment'] + +class ResProfileSerializer(serializers.RelatedField): + def to_representation(self, value): + comm = value.filter(active=True) + return MeCommentProfile(comm, context = self.context, many=True).data + + +class MeCommentProfile(CommentSerializer): + project = serializers.SerializerMethodField('get_project') + response = ResProfileSerializer(read_only=True) + time_line = serializers.SerializerMethodField('get_time_line') + def get_time_line(self, obj): + if obj.time_line: + return obj.time_line.get_query_url + def get_project(self, obj): + if obj.project: + id = obj.project.id + name = obj.project.title + slug = obj.project.get_absolute_url() + return {'id':id, 'title':name, 'slug':slug } + class Meta: + model = Comment + fields = ['id', 'name', 'body', 'get_date', 'is_me_comment',\ + 'response', 'user', 'project', 'my_like', 'view', \ + 'is_me_project', 'parent', 'project_id', 'time_line', 'timeline_id'] + +class MyTagsSerializer(serializers.ModelSerializer): + class Meta: + model = MySkils + fields = '__all__' + +class BagsListSerializer(serializers.ModelSerializer): + skils = MyTagsSerializer(many=True, read_only=True) + count_viewers = serializers.SerializerMethodField('get_count_viewers') + count_comments = serializers.SerializerMethodField('get_count_comments') + def get_count_comments(self, obj): + return obj.comments.filter(time_line=None, active=True).exclude(name='', body='').count() + def get_count_viewers(self, obj): + return len(obj.viewers['ip']) + class Meta: + model = WorkPost + fields = ['id', 'title', 'get_absolute_url', 'slug', 'link', 'skils', \ + 'get_author', 'get_tranc_content','get_date', 'get_username',\ + 'count_viewers', 'count_comments'] + +class BagsDetailSerializer(serializers.ModelSerializer): + skils = MyTagsSerializer(many=True, read_only=True) + comments = serializers.SerializerMethodField('get_comments') + viewer = serializers.SerializerMethodField('get_viewer') + raiting = serializers.SerializerMethodField('get_raiting') + count_viewers = serializers.SerializerMethodField('get_count_viewers') + count_comments = serializers.SerializerMethodField('get_count_comments') + list_posts = serializers.SerializerMethodField('get_list_posts_blog') + type_content = serializers.SerializerMethodField('get_type_post') + def get_type_post(self, obj): + return obj.type_content + def get_list_posts_blog(self, obj): + posts = TimeLine.activeted.filter(project__id=obj.id, blog__isnull=False)\ + .values('project__id')\ + .values('blog__id', 'blog__title', 'blog__slug')\ + .order_by('blog__date_created') + return dict(posts=posts, count_posts=posts.count()) + def get_count_comments(self, obj): + return obj.comments.filter(time_line=None, active=True).count() + def get_count_viewers(self, obj): + ip = self.context['request'].META.get('HTTP_X_REAL_IP')\ + or self.context['request'].META.get('REMOTE_ADDR')\ + or self.context['request'].META.get('HTTP_X_FORWARDED_FOR') + if ip not in obj.viewers['ip']: + obj.viewers['ip'].append(ip) + WorkPost.objects.filter(id=obj.id).update(viewers=obj.viewers) + return len(obj.viewers['ip']) + def get_raiting(self, obj): + rait_list = Raiting.objects.filter(project__id=obj.id) + raiting = rait_list.aggregate(Avg('num_rait'))['num_rait__avg'] + count_user = rait_list.count() + return {'raiting':raiting, 'users':count_user} + def get_viewer(self, obj): + request_userid = self.context.get('request', []).user.id + if request_userid: + user_pofile = Profile.user_active.filter(user__id=request_userid) + if user_pofile: + item_full_name = user_pofile[0].user.get_full_name() + return {'id':request_userid, 'name':item_full_name} + def get_comments(self, obj): + comments = Comment.activeted.filter(project__id = obj.id, comments_res__isnull=True).order_by('-created', ) + return CommentSerializer(comments, many=True, context = self.context).data + class Meta: + model = WorkPost + fields = ['id','title', 'slug', 'link', 'skils', 'get_author', \ + 'content', 'comments','get_date', 'viewer', 'get_username', \ + 'key_words', 'description', 'raiting', 'comment_push', 'count_viewers',\ + 'count_comments', 'list_posts', 'type_content'] + +class BagsCreateBlogSerializer(serializers.ModelSerializer): + class Meta: + model = WorkPost + fields = ['id','title', 'slug', 'photo', 'skils', 'content', 'type_content',\ + 'key_words', 'description', 'get_absolute_url', 'link', 'is_published', 'comment_push'] + def create(self, validated_data): + user = get_object_or_404(User, id=self.context['request'].user.id) + str_slug = slugify(validated_data['title'], allow_unicode=True) + '-by-' +slugify(user.username, allow_unicode=True) + validated_data['slug'] = str_slug + validated_data['author'] = user + return super().create(validated_data) + +class CreateCommentSerializer(serializers.ModelSerializer): + class Meta: + model = Comment + fields = ['id', 'name', 'body', 'ip', 'sess', 'project', 'time_line'] + def create(self, validated_data): + project = validated_data.get('project') + if project: + if not validated_data['project'].comment_push: + raise PermissionDenied() + response = self.context['response'] + if response: + comment_main = get_object_or_404(Comment, id=response) + # validated_data['child'] = True + comment_child = Comment.objects.create(**validated_data) + comment_main.response.add(comment_child.id) + comment_main.save(force_update=['response']) + return comment_child + return super().create(validated_data) + +class ProfileFormSerializer(serializers.ModelSerializer): + user = serializers.SerializerMethodField('get_user') + count_projects = serializers.SerializerMethodField('get_count_project') + count_posts = serializers.SerializerMethodField('get_count_posts') + count_events = serializers.SerializerMethodField('get_count_events') + myskils = serializers.SerializerMethodField('get_myskils') + links = serializers.SerializerMethodField('get_mylinks') + project_tags = serializers.SerializerMethodField('get_project_tags') + blog_tags = serializers.SerializerMethodField('get_blog_tags') + links_project = serializers.SerializerMethodField('get_links_project') + is_my_follower = serializers.SerializerMethodField('get_is_my_follower') + def get_is_my_follower(self, obj): + is_my_follower = UserFollowers.objects.filter(user__id=self.context['request'].user.id, follower=obj).count() + return True if is_my_follower else False + def get_links_project(self, obj): + projects = obj.user.work_post.filter(type_content=1, + is_published=True, + timeline_project__isnull=False).values_list('id').distinct() + projects = WorkPost.published.filter(id__in=projects) + return projects.values('id', 'title') + def get_tags_project(self, obj, cnt): + projects = obj.user.work_post.filter(type_content=cnt, is_published=True) + skils = [] + for projct in projects: + for skil in projct.skils.values(): + if skil['slug'] not in skils: + skils.append(skil['slug']) + skils = MySkils.objects.filter(slug__in=skils) + return skils.values() + def get_blog_tags(self, obj): + return self.get_tags_project(obj, 2) + def get_project_tags(self, obj): + return self.get_tags_project(obj, 1) + def get_mylinks(self, obj): + return json.loads(obj.links) + def get_myskils(self, obj): + list_skils = obj.myskils.values() + max_value = 0 + for item in list_skils: + count = TimeLine.activeted.filter(skils__name=item['name'], author=obj.user).count() + item['timeline_count'] = count + if count > max_value: + max_value = count + for item in list_skils: + if max_value: + item['percent_timeline'] = int(item['timeline_count']*100 / max_value) + else: + item['percent_timeline'] = 0 + return list_skils + def get_count_events(self, obj): + return TimeLine.activeted.filter(author=obj.user).count() + def get_count_posts(self, obj): + return WorkPost.BlogPublished.filter(author=obj.user).count() + def get_count_project(self, obj): + return WorkPost.PortfolioPublished.filter(author=obj.user).count() + def get_user(self, obj): + return User.objects.filter(id=obj.user.id)[0].get_full_name() + class Meta: + model = Profile + fields = ['id', 'user', 'myskils', 'photo_user', 'count_projects',\ + 'count_posts', 'count_events', 'myskils', 'links', \ + 'project_tags', 'blog_tags', 'links_project', 'is_my_follower'] + + +class ListPostsUserSerializer(serializers.ModelSerializer): + skils = MyTagsSerializer(many=True, read_only=True) + raiting = serializers.SerializerMethodField('get_raiting') + def get_raiting(self, obj): + rait_list = Raiting.objects.filter(project__id=obj.id) + raiting = rait_list.aggregate(Avg('num_rait'))['num_rait__avg'] + count_user = rait_list.count() + return {'raiting':raiting, 'users':count_user} + class Meta: + model = WorkPost + fields = ['id','title', 'slug', 'link', 'skils', 'get_absolute_url', \ + 'type_content', 'is_published', 'key_words', 'description', \ + 'date_created', 'comment_push', 'raiting'] + +class SetRaitingSerializer(serializers.ModelSerializer): + class Meta: + model = Raiting + fields = ['project', 'user'] + +class SetLikeSerializer(serializers.ModelSerializer): + class Meta: + model = LikeObject + fields = ['object_id', 'user'] + def create(self, validated_data): + object_id = validated_data.pop('object_id') + if self.context['request'].data['type'] == 'timeline': + content_object = get_object_or_404(TimeLine.activeted, id=object_id) + if self.context['request'].data['type'] == 'comment': + content_object = get_object_or_404(Comment.activeted, id=object_id) + validated_data['content_object'] = content_object + return super().create(validated_data) + +class ChangeEventSerializer(serializers.ModelSerializer): + class Meta: + model = TimeLine + fields = ['content', 'blog', 'skils'] + +class AddEventSerializer(serializers.ModelSerializer): + class Meta: + model = TimeLine + fields = ['content', 'project', 'blog', 'skils', 'author', 'id'] + +class SetViewSerializer(serializers.ModelSerializer): + class Meta: + model = ViewObject + fields = ['object_id', 'user'] + def create(self, validated_data): + object_id = validated_data.pop('object_id') + if self.context['request'].data['type'] == 'timeline': + content_object = get_object_or_404(TimeLine.activeted, id=object_id) + if self.context['request'].data['type'] == 'comment': + content_object = get_object_or_404(Comment.activeted, id=object_id) + validated_data['content_object'] = content_object + return super().create(validated_data) + +class UserFollowerUpdateSerializer(serializers.ModelSerializer): + def update(self, instance, validated_data): + if not isinstance(instance[0], UserFollowers): + raise ParseError(detail=_('instance не является UserFollowers')) + if not validated_data.get('follower', []): + raise NotFound(detail=_('Вы не отписались и не подписались')) + if validated_data.get('user').id == validated_data.get('follower')[0].user.id: + raise NotFound(detail=_('Нельзя подписываться на самого себя')) + my_follow = [] + for item in instance[0].follower.values('id'): + my_follow.append(item['id']) + if validated_data['follower'][0].id in my_follow: + my_follow.remove(validated_data['follower'][0].id) + else: + my_follow.append(validated_data['follower'][0].id) + instance[0].follower.set(my_follow) + return instance[0] + class Meta: + model = UserFollowers + fields = ['user', 'follower', ] + +class UserDetailFollowSerializer(serializers.RelatedField): + def get_count_followers(self, user): + try: + return UserFollowers.objects.get(user__id=self.context['request'].user.id)\ + .follower.filter(user=user).count() + except UserFollowers.DoesNotExist: + return 0 + def to_representation(self, value): + count_project = WorkPost.PortfolioPublished.filter(author=value.user).count() + count_posts = WorkPost.BlogPublished.filter(author=value.user).count() + count_followers = self.get_count_followers(value.user) + return {'id':value.id, + 'photo':value.photo.name, + 'full_name': value.user.get_full_name(), + 'username': value.user.username, + 'count_project': count_project, + 'count_posts': count_posts, + 'count_followers': count_followers + } + +class UserFollowerListSerializer(serializers.ModelSerializer): + follower = UserDetailFollowSerializer(many=True, read_only=True) + class Meta: + model = UserFollowers + fields = ['id', 'user', 'follower', ] + diff --git a/portfolio/tests.py b/portfolio/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/portfolio/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/portfolio/urls.py b/portfolio/urls.py new file mode 100644 index 0000000..9acaecd --- /dev/null +++ b/portfolio/urls.py @@ -0,0 +1,58 @@ +from django.urls import path, re_path, include +from .views import PortfolioViewSet, BlogViewSet, PageViewSet, \ + CreateComment, UpdateComment, getMenu,\ + EventViewList, ListMeComment, \ + ProfileForm, getSkills, addSkill, CreatePostPortfolio,\ + getTypeContent, WorkedMePosts, GetContentForEdit,\ + SetRaiting, SetLikes, AddEditEventView, getMyPics,\ + dowloadMyPics, deleteMyPic, setMyPicAva, GetDocResum,\ + UserResume, SetView, UserFollowersView, FollowPostsMe,\ + FollowEventsMe, UploadFileToPost +from .router import CustomReadOnlyRouter +from django.contrib.sitemaps import views + +router = CustomReadOnlyRouter() +router.register('portfolio', PortfolioViewSet, basename='portfolio') +router.register('blog', BlogViewSet, basename='blog') +urlpatterns = router.urls + +urlpatterns += [ + path('page/', PageViewSet.as_view({'get':'retrieve'}), name='page-detail'), + path('comment/create', CreateComment.as_view()), + path('comment/delete/', UpdateComment.as_view({'get':'update'})), + path('getmenu', getMenu.as_view()), + path('events', EventViewList.as_view({'get':'list'})), + path('detail/user/comments', ListMeComment.as_view({'get':'list'})), + re_path(r'card/user/(?P\w+)', ProfileForm.as_view({'get':'retrieve'})), + path('detail/user/allskills', getSkills.as_view()), + path('detail/user/addskill', addSkill.as_view()), + path('detail/user/post/create', CreatePostPortfolio.as_view({'post':'create'})), + path('detail/user/post/delete', CreatePostPortfolio.as_view({'post':'destroy'})), + path('detail/user/post/update', CreatePostPortfolio.as_view({'post':'partial_update'})), + path('detail/user/typecontent', getTypeContent.as_view()), + path('detail/user/posts', WorkedMePosts.as_view({'get':'list'})), + path('detail/user/post/content/', GetContentForEdit.as_view()), + path('post/raiting/set', SetRaiting.as_view()), + path('comment/like/set', SetLikes.as_view({'post':'create'})), + path('comment/like/delete', SetLikes.as_view({'post':'destroy'})), + path('events/user/addevent', AddEditEventView.as_view({'post': 'create'})), + path('events/user/update/', AddEditEventView.as_view({'put': 'partial_update'})), + path('events/user/delete/', AddEditEventView.as_view({'delete': 'destroy'})), + path('detail/user/pics/list', getMyPics.as_view()), + path('detail/user/pics/download', dowloadMyPics.as_view()), + path('detail/user/pics/delete', deleteMyPic.as_view()), + path('detail/user/pics/setava', setMyPicAva.as_view()), + path('detail/user/getdoc', GetDocResum.as_view()), + path('detail/user/resume/get', UserResume.as_view()), + path('detail/user/resume/save', UserResume.as_view()), + path('comment/view/set', SetView.as_view({'post':'create'})), + path('detail/user/follower', UserFollowersView.as_view({'get': 'list', + 'put': 'update'})), + path('follow/posts/', include([ + path('events', FollowEventsMe.as_view({'get': 'list'})), + path('portfolio', FollowPostsMe.as_view({'get': 'list'}), {'postfix': 'portfolio'}), + path('blog', FollowPostsMe.as_view({'get': 'list'}), {'postfix': 'blog'}) + ])), + path('detail/post/image/set', UploadFileToPost.as_view()), + # path('detail/user/notify', NotifyList.as_view({'get': 'list'})) +] \ No newline at end of file diff --git a/portfolio/views.py b/portfolio/views.py new file mode 100644 index 0000000..717f92c --- /dev/null +++ b/portfolio/views.py @@ -0,0 +1,778 @@ +import os +from django.http import HttpResponse +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from .serializers import BagsListSerializer, \ + BagsDetailSerializer, \ + CreateCommentSerializer, \ + TimeLineSerializer,\ + MeCommentProfile, \ + ProfileFormSerializer,\ + BagsCreateBlogSerializer,\ + ListPostsUserSerializer,\ + SetRaitingSerializer,\ + SetLikeSerializer,\ + ChangeEventSerializer,\ + AddEventSerializer,\ + CommentSerializer,\ + SetViewSerializer,\ + MyLikesSerializer,\ + UserFollowerUpdateSerializer,\ + UserFollowerListSerializer +from .models import WorkPost, Comment, TimeLine, \ + Raiting, LikeObject, ViewObject, UserFollowers + +from rest_framework.views import APIView +from captcha.models import CaptchaStore +from bagchat.settings import MEDIA_ROOT, BASE_DIR, TIME_ZONE +from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework import FilterSet +import django_filters +from rest_framework import filters, status, mixins, viewsets +from rest_framework.pagination import PageNumberPagination +from django.views.decorators.http import require_GET +from django.contrib.auth.models import User +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from .models import Profile, MySkils +from base.views import MixinAuthBag +from django.forms.models import model_to_dict +from django.utils.text import slugify +from django.db import IntegrityError +from django.core.exceptions import PermissionDenied +from django.contrib.contenttypes.models import ContentType +from firebase_admin import storage +import base64 +import json +from docxtpl import DocxTemplate +from docxtpl import InlineImage +from docx.shared import Mm +from docxtpl import RichText +import jinja2 +from datetime import datetime, timedelta +from django.utils import timezone, dateformat +from django.utils.translation import gettext_lazy as _ +from base.service import FireBaseStorage +from django.utils.crypto import get_random_string +from django.db.models import Q +from rest_framework.exceptions import NotFound +# Create your views here. + +LIMIT_COMMENT_ADMIN = 3 +LIMIT_POSTS_BLOG = 3 +LIMIT_EVENTS = 3 +HOSTPICS = 'https://storage.googleapis.com/antonio-glyzin.appspot.com' +LINK_SITE = 'http://localhost:8080' + +class ProfileForm(viewsets.ReadOnlyModelViewSet): + ''' + Вывод пользовательской акеты по username + ''' + queryset = Profile.user_active + serializer_class = ProfileFormSerializer + lookup_field = 'user__username' + +class getMenu(APIView): + ''' + Формирование меню для зарегистрированного пользователя + ''' + def get(self, request): + + list_skils = [] + if (request.user.id): + skils = WorkPost.published.filter(author__id=request.user.id, type_content=2).values('skils__id','skils__name', 'skils__slug') + for item in skils: + if item['skils__id'] not in list_skils: + list_skils.append(item['skils__id']) + list_skils = MySkils.objects.filter(id__in=list_skils).values() + return Response(status=status.HTTP_200_OK, data={'port':[], 'blog':[]}) + + +class WorkedMePosts(MixinAuthBag, viewsets.ModelViewSet): + ''' + Выводит статьи в таблицу в адимн панели + ''' + serializer_class = ListPostsUserSerializer + filter_backends = [filters.OrderingFilter] + ordering_fields = ['date_created', ] + ordering = ['-date_created', ] + def get_queryset(self): + return WorkPost.objects.filter(author__id=self.request.user.id) + + +class getSkills(MixinAuthBag, APIView): + ''' + Получение своих скилов. + Используется при пользовательском создание скилов. + ''' + def get(self, request): + skills = MySkils.objects.all().values() + return Response(status=status.HTTP_200_OK, data=skills) + +class getTypeContent(MixinAuthBag, APIView): + ''' + При создание статьи в админ панели, есть выбор контента + ''' + def get(self, request): + type_cont = [ + {'name':'Портфолио', 'code': 1}, + {'name':'Блог', 'code': 2} + ] + return Response(status=status.HTTP_200_OK, data=type_cont) + +class addSkill(MixinAuthBag, APIView): + ''' + Добавление пользователем скилов в админ панели + ''' + parser_classes = [JSONParser] + def post(self, request): + skill = request.data.get('skill') + if skill: + try: + skill = MySkils.objects.create(name=skill, slug=slugify(skill, allow_unicode=True)) + return Response(status=status.HTTP_200_OK, data=model_to_dict(skill)) + except IntegrityError as err: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': 'Такое умение уже существует'}) + return Response(status=status.HTTP_400_BAD_REQUEST) + +# @require_GET +# def robots_txt(request): +# lines = [ +# "User-Agent: *", +# f"Sitemap: {MY_HOST}/sitemap.xml", +# f"Host: {MY_HOST}", +# ] +# return HttpResponse("\n".join(lines), content_type="text/plain") + +class PaginationComment(PageNumberPagination): + page_size = LIMIT_COMMENT_ADMIN + page_size_query_param = 'limit' + +class ListMeComment(MixinAuthBag, mixins.ListModelMixin, + viewsets.GenericViewSet): + ''' + Выводит комментарии в админ панели + ''' + serializer_class = MeCommentProfile + pagination_class = PaginationComment + filter_backends = [filters.OrderingFilter] + ordering_fields = ['id', ] + ordering = ['-id', ] + def get_queryset(self): + user_id = self.request.user.id + comm = Comment.activeted.filter(Q(comments_res__isnull=True) & (Q(project__author__id=user_id) | \ + Q(user__user__id=user_id) | Q(time_line__author__id=user_id)) ) + return comm + +class PaginationPages(PageNumberPagination): + page_size = LIMIT_POSTS_BLOG + page_size_query_param = 'limit' + +class PaginationEvents(PageNumberPagination): + page_size = LIMIT_EVENTS + page_size_query_param = 'limit' + +class RangeFilterEvents(django_filters.FilterSet): + date = django_filters.DateFromToRangeFilter(field_name='date_created') + tags = django_filters.CharFilter(field_name='skils__slug') + project = django_filters.CharFilter(field_name='project') + class Meta: + model = TimeLine + fields = ['date_created', 'tags', 'project'] + +class MixEventViewParams: + filter_backends = [filters.OrderingFilter, DjangoFilterBackend] + filterset_class = RangeFilterEvents + serializer_class = TimeLineSerializer + ordering_fields = ['date_created', ] + ordering = ['-date_created', ] + pagination_class = PaginationEvents + +class EventViewList(MixEventViewParams, + viewsets.ReadOnlyModelViewSet): + ''' + Вывод событий в общий блок для просмотра + ''' + def get_queryset(self): + queryset = TimeLine.activeted + username = self.request.query_params.get('user') + if username is not None: + queryset = queryset.filter(author__username=username) + return queryset + raise NotFound() + +class AddEditEventView(MixinAuthBag, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + ''' + Добавление и редактирование событий для пользователя + ''' + queryset = TimeLine.objects.all() + parser_classes = (JSONParser, ) + def get_object(self): + obj = get_object_or_404(self.queryset, id=self.kwargs['id']) + self.check_object_permissions(self.request, obj) + return obj + def check_object_permissions(self, request, obj): + if obj.author.id != request.user.id: + raise PermissionDenied() + def get_serializer_class(self): + if self.request.method == 'POST': + return AddEventSerializer + elif self.request.method == 'PUT': + return ChangeEventSerializer + def create(self, request, *args, **kwargs): + request.data['author'] = request.user.id + post = super().create(request, *args, **kwargs) + return self.response_object(post.data.get('id'), post.status_code) + def response_object(self, id, status): + post = get_object_or_404(self.queryset, id=id) + post = TimeLineSerializer(post, context={'request': self.request}).data + return Response(status=status, data=post) + +class FilterBagList(FilterSet): + tags = django_filters.CharFilter(field_name='skils__slug') + class Meta: + model = WorkPost + fields = ['tags'] + +class SearchTitleOrContent(filters.SearchFilter): + def get_search_fields(self, view, request): + if 'title' in request.query_params: + return ['$title'] + return super().get_search_fields(view, request) + +class MixParamBag: + filter_backends = [DjangoFilterBackend, SearchTitleOrContent, filters.OrderingFilter] + filter_class = FilterBagList + search_fields = ['$title', '$content', ] + ordering_fields = ['date_created', ] + ordering = ['-date_created', ] + pagination_class = PaginationPages + +class BagMixin(MixParamBag): + ''' + Вывод контентной части для портфолио и блога + ''' + lookup_field = 'slug' + def get_serializer_class(self): + if self.action == 'list': + return BagsListSerializer + elif self.action == "retrieve": + return BagsDetailSerializer + def get_queryset(self): + if 'portfolio' in self.request.path: + queryset = WorkPost.PortfolioPublished + elif 'blog' in self.request.path: + queryset = WorkPost.BlogPublished + username = self.request.query_params.get('user') + if username is not None: + queryset = queryset.filter(author__username=username) + return queryset + + +class PortfolioViewSet(BagMixin, viewsets.ReadOnlyModelViewSet): + pass + +class BlogViewSet(BagMixin, viewsets.ReadOnlyModelViewSet): + pass + +class PageViewSet(viewsets.ReadOnlyModelViewSet): + ''' + Вывод одной статьи + ''' + lookup_field = 'slug' + queryset = WorkPost.PagePublished + serializer_class = BagsDetailSerializer + +class UpdateComment(MixinAuthBag, mixins.UpdateModelMixin, + viewsets.GenericViewSet): + ''' + Нужен для удаление комментариев. Удаление пустых родителей. + ''' + queryset = Comment.activeted + def update(self, request, *args, **kwargs): + self.queryset.filter(id=kwargs['id'], user__user__id=request.user.id).delete() + return Response(status=status.HTTP_200_OK) + +class CreatePostPortfolio(MixinAuthBag, mixins.UpdateModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + ''' + Взаимодействия с постом в адимн панели + ''' + parser_classes = (MultiPartParser, FormParser, JSONParser,) + serializer_class = BagsCreateBlogSerializer + queryset = WorkPost.objects.all() + def create(self, request, *args, **kwargs): + try: + post = super().create(request, *args, **kwargs) + return self.response_object(post.data['id'], status.HTTP_201_CREATED) + except IntegrityError: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': 'Пост с таким название уже существует'}) + def get_object(self): + post = get_object_or_404(self.queryset, id=self.request.data['id']) + self.check_object_permissions(self.request, post) + return post + def check_object_permissions(self, request, obj): + if obj.author.id != request.user.id: + raise PermissionDenied() + def update(self, request, *args, **kwargs): + post = super().update(request, *args, **kwargs) + return self.response_object(post.data['id'], status.HTTP_200_OK) + def response_object(self, id, status): + post = get_object_or_404(self.queryset, id=id) + post = ListPostsUserSerializer(post).data + return Response(status=status, data=post) + +class GetContentForEdit(APIView): + ''' + Подгрузка содержание поста для редактирования + ''' + def get(self, request, id): + post = get_object_or_404(WorkPost, id=id) + return Response(status=status.HTTP_200_OK, data=post.content) + + +class CreateComment(APIView): + ''' + Создание комментариев, как для зарегистрированных, + так и для обычных пользователей + ''' + parser_classes = (JSONParser, ) + def post(self, request): + data = request.data + ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + if not request.user.id: + captcha_0 = data.pop('captcha_0') + captcha_1 = data.pop('captcha_1') + try: + CaptchaStore.remove_expired() + captcha_1_low = captcha_1.lower() + CaptchaStore.objects.get( + response=captcha_1_low, hashkey=captcha_0, expiration__gt=timezone.now() + ).delete() + except: + return Response(status=status.HTTP_409_CONFLICT) + sess = request.META.get('HTTP_X_CSRFTOKEN') or '' + data['sess'] = sess + item = Comment.activeted.filter(name=data['name'].strip(), project__id=data['project']) + if item and (item[0].sess != sess): + return Response(status=status.HTTP_423_LOCKED) + data['ip'] = ip + if request.user.id: + name = get_object_or_404(User, id=request.user.id).get_full_name() + data['name'] = name + response = data.pop('response', []) + serializer = CreateCommentSerializer(data=data, context={'response':response}) + if serializer.is_valid(): + obj = {} + if request.user.id: + user = Profile.PortfolioActive.filter(user__id=request.user.id) + if not user: + return Response(status=status.HTTP_401_UNAUTHORIZED) + serializer.save(active=True, user=user[0]) + my_comm = get_object_or_404(Comment.activeted, id=serializer.data['id']) + obj = CommentSerializer(my_comm, context={'request':request}).data + else: + serializer.save() + return Response(status=status.HTTP_201_CREATED, data=obj) + return Response(status=status.HTTP_400_BAD_REQUEST) + +class SetRaiting(MixinAuthBag, APIView): + ''' + Установка рейтинга для зарегистрированного пользователя + ''' + parser_classes = (JSONParser, ) + def post(self, request): + project = request.data['project'] + raiting = request.data['raiting'] + user = request.user.id + valid = SetRaitingSerializer(data={'project':project, 'user':user}) + if valid.is_valid(): + project = valid.validated_data['project'] + user = valid.validated_data['user'] + obj, response = Raiting.objects.update_or_create(project=project, user=user, defaults={'num_rait':raiting}) + return Response(status=status.HTTP_200_OK, data={'plus':response}) + return Response(status=status.HTTP_400_BAD_REQUEST) + + +class SetLikes(MixinAuthBag, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + ''' + Ставит и удаляет лайки для зарегистрированных + ''' + queryset = LikeObject.objects.all() + serializer_class = SetLikeSerializer + parser_classes = (JSONParser, ) + def get_likes(self, request, res): + data = '' + if request.data['type'] == 'comment': + object_id = request.data['object_id'] + queryset = Comment.activeted + if request.data['type'] == 'timeline': + object_id = request.data['object_id'] + queryset = TimeLine.activeted + comm = get_object_or_404(queryset, id=object_id) + data = MyLikesSerializer(comm, context={'request':request}).data + return Response(status=res.status_code, data=data) + def create(self, request, *args, **kwargs): + request.data['user'] = request.user.id + res = super().create(request, *args, **kwargs) + return self.get_likes(request, res) + def get_object(self): + id_user = self.request.user.id + object_id = self.request.data['object_id'] + if self.request.data['type'] == 'timeline': + type = ContentType.objects.get_for_model(TimeLine) + if self.request.data['type'] == 'comment': + type = ContentType.objects.get_for_model(Comment) + object = get_object_or_404(self.queryset, user__id=id_user, object_id=object_id, content_type__pk=type.id) + return object + +class getMyPics(MixinAuthBag, APIView): + ''' + Берет список картинок пользователя из storage + ''' + def get(self, request): + username = get_object_or_404(User, id=request.user.id).username + bucket = storage.bucket() + newlist_pics = [] + list_avatar = bucket.list_blobs(prefix=f'portfolio/users/{username}/avatar') + list_portfolio = bucket.list_blobs(prefix=f'portfolio/users/{username}/portfolio') + for ava in list_avatar: + split_name = ava.name.split('.')[-2] + str_name = split_name[-5:] + if str_name != '_crop': + newlist_pics.append({'itemImageSrc':ava.name, + 'thumbnailImageSrc':ava.name, + 'title':split_name.split('/')[-1], + 'is_ava': True}) + for pic in list_portfolio: + split_name = pic.name.split('.')[-2] + newlist_pics.append({'itemImageSrc':pic.name, + 'thumbnailImageSrc':pic.name, + 'title':split_name.split('/')[-1], + 'is_ava': False}) + return Response(status=status.HTTP_200_OK, data=newlist_pics) + +class dowloadMyPics(MixinAuthBag, APIView): + ''' + Для скачаивания файла из адинки + ''' + def get(self, request): + file = request.query_params['img'].strip() + bucket = storage.bucket() + blob = bucket.blob(file) + contents = blob.download_as_bytes() + base64_data = base64.b64encode(contents) + return Response(status=status.HTTP_200_OK, data=base64_data) + +class deleteMyPic(MixinAuthBag, APIView): + ''' + Удаление файла + ''' + def delete(self, request): + username = get_object_or_404(User, id=request.user.id).username + file = request.query_params['img'].strip() + if file.split('/')[2] != username: + return Response(status=status.HTTP_403_FORBIDDEN) + bucket = storage.bucket() + blob = bucket.blob(file) + blob.delete() + data = {} + if file.split('/')[3] == 'avatar': + list_avatar = bucket.list_blobs(prefix=f'portfolio/users/{username}/avatar') + for ava in list_avatar: + if file in ava.name: + ava.delete() + user = Profile.objects.filter(user__username=username, photo_user=f'{HOSTPICS}/{file}') + if user: + crop = '5d26f032932c07b264f6badb0f0ef.jpg.100x100_q85_crop.jpg' + origin = '5d26f032932c07b264f6badb0f0ef.jpg' + photo_url = 'https://storage.googleapis.com/antonio-glyzin.appspot.com/portfolio/photo/' + Profile.objects.filter(user__username=username).update(photo_user=f'{photo_url}{origin}', photo=f'{photo_url}{crop}') + data['photo_user'] = f'{photo_url}{origin}' + data['photo'] = f'{photo_url}{crop}' + return Response(status=status.HTTP_200_OK, data=data) + return Response(status=status.HTTP_204_NO_CONTENT) + +class setMyPicAva(MixinAuthBag, APIView): + ''' + Обнавления картинки профиля из firebase storage + thumbnailImageSrc = origin + ''' + parser_classes = (JSONParser, ) + def put(self, request): + if not request.data['is_ava']: + return Response(status=status.HTTP_400_BAD_REQUEST) + bucket = storage.bucket() + username = get_object_or_404(User, id=request.user.id).username + list_avatar = bucket.list_blobs(prefix=f'portfolio/users/{username}/avatar') + data = {} + for ava in list_avatar: + split_name = ava.name.split('.')[-2] + str_name = split_name[-5:] + if str_name == '_crop': + if request.data['thumbnailImageSrc'] in ava.name: + Profile.user_active.filter(user__id=request.user.id)\ + .update(photo=f'{HOSTPICS}/{ava.name}', photo_user=f"{HOSTPICS}/{request.data['thumbnailImageSrc']}") + data = { + 'photo': f'{HOSTPICS}/{ava.name}', + 'photo_user': f"{HOSTPICS}/{request.data['thumbnailImageSrc']}" + } + return Response(status=status.HTTP_200_OK, data=data) + +class UserResume(MixinAuthBag, APIView): + ''' + Отдает резюме для сайта. Сохраняет в базе. + ''' + parser_classes = (JSONParser, ) + def get(self, request): + resume = get_object_or_404(Profile.user_active, user__id=request.user.id).resume + return Response(status=status.HTTP_200_OK, data=resume) + def put(self, request): + Profile.user_active.filter(user__id=request.user.id).update(resume=request.data) + return Response(status=status.HTTP_200_OK) + +class GetDocResum(MixinAuthBag, APIView): + ''' + Формирует и отдает пользователю резюме + ''' + def get(self, request): + doc = DocxTemplate(os.path.join(BASE_DIR, "resum.docx")) + def get_date(value): + day = datetime.strptime(value,"%Y-%m-%dT%H:%M:%S.%fZ").strftime("%d") + mon = datetime.strptime(value,"%Y-%m-%dT%H:%M:%S.%fZ").strftime("%m") + year = datetime.strptime(value,"%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y") + hor = datetime.strptime(value,"%Y-%m-%dT%H:%M:%S.%fZ").strftime("%H") + tz = datetime(year=int(year), month=int(mon), day=int(day), hour=int(hor)) + timedelta(hours=4) + return tz + + def func_date(value): + source = RichText("") + date = get_date(value) + date = dateformat.format(datetime(day=date.day, year=date.year, month=date.month), 'd E Y') + source.add(date) + return source + + def split_text(val): + return val.split('\n') + + def get_link(val): + source = RichText("") + source.add('Источник',url_id=doc.build_url_id(val), color='#1856af', underline=True) + return source + + def get_inerval_date(vals): + str = '' + for i, val in enumerate(vals): + date = dateformat.format(get_date(val), 'E Y') + str += " - " + date if i == 1 else date + str += ' - до настоящего времени' if len(vals) == 1 else '' + return str + + def get_myskils(myskils, user): + list_skils = myskils.values() + max_value = 0 + for item in list_skils: + count = TimeLine.activeted.filter(skils__name=item['name'], author=user.user).count() + item['timeline_count'] = count + if count > max_value: + max_value = count + for item in list_skils: + if max_value: + item['percent_timeline'] = int(item['timeline_count']*100 / max_value) + else: + item['percent_timeline'] = 0 + return list_skils + + user = get_object_or_404(Profile.user_active, user__id=request.user.id) + mypath = MEDIA_ROOT + mypathdoc = os.path.join(mypath, f"generated_doc_{user.user.username}.docx") + mypathfile = '' + try: + bucket = storage.bucket() + photo_name_path = user.photo_user.replace(HOSTPICS,'')[1:] + blob = bucket.blob(photo_name_path) + filename = user.photo_user.split('/')[-1] + contents = blob.download_as_bytes() + mypathfile = os.path.join(mypath, user.user.username + filename) + with open(mypathfile, 'wb') as file: + file.write(contents) + img = InlineImage(doc, mypathfile, width=Mm(40)) + except BaseException as err: + print(err) + img = '' + mypathfile = '' + resume = user.resume + userWorks = sorted(resume['userWorks'], \ + key=lambda x: x['years'][0] if len(x['years']) else '', reverse=True) + userStudy = sorted(resume['userStudy'], \ + key=lambda x: x['years'][0] if len(x['years']) else '', reverse=True) + plusStudy = sorted(resume['plusStudy'], \ + key=lambda x: x['years'][0] if len(x['years']) else '', reverse=True) + resume['userWorks'] = userWorks + resume['userStudy'] = userStudy + resume['plusStudy'] = plusStudy + full_name = user.user.get_full_name() + skills = get_myskils(user.myskils.values(), user) + contact = json.loads(user.links) + jinja_env = jinja2.Environment() + jinja_env.filters['date'] = func_date + jinja_env.filters['split_text'] = split_text + jinja_env.filters['get_link'] = get_link + jinja_env.filters['get_inerval_date'] = get_inerval_date + projects = user.user.work_post.filter(is_published=True, type_content=1).values('title', 'slug') + for project in projects: + project['link'] = LINK_SITE + '/portfolio/post/' + project['slug'] + context = { 'img':img, 'contact': contact, + 'full_name': full_name, + 'skills': skills, + 'projects': projects, + 'resume': resume, + 'portfolio': f'{LINK_SITE}/card/user/{user.user.username}'} + doc.render(context, jinja_env) + doc.save(mypathdoc) + down_file = '' + with open(mypathdoc, 'rb') as file: + down_file = file.read() + base64_data = base64.b64encode(down_file) + if mypathfile: + os.remove(mypathfile) + os.remove(mypathdoc) + return Response(status=status.HTTP_200_OK, data={'file':base64_data, 'user':user.user.username}) + +class SetView(mixins.CreateModelMixin, + viewsets.GenericViewSet): + ''' + Ставит просмотры для всех + ''' + queryset = ViewObject.objects.all() + serializer_class = SetViewSerializer + parser_classes = (JSONParser, ) + def create(self, request, *args, **kwargs): + request.data['user'] = request.user.id + ip = request.META.get('HTTP_X_REAL_IP')\ + or request.META.get('REMOTE_ADDR')\ + or request.META.get('HTTP_X_FORWARDED_FOR') + if request.data['type'] == 'comment': + comm = get_object_or_404(Comment.activeted, id=request.data['object_id']) + if ip not in comm.ip_view['ip']: + comm.ip_view['ip'].append(ip) + Comment.activeted.filter(id=request.data['object_id']).update(ip_view=comm.ip_view) + elif request.data['type'] == 'timeline': + comm = get_object_or_404(TimeLine.activeted, id=request.data['object_id']) + if ip not in comm.ip_view['ip']: + comm.ip_view['ip'].append(ip) + TimeLine.activeted.filter(id=request.data['object_id']).update(ip_view=comm.ip_view) + if request.user.id: + return super().create(request, *args, **kwargs) + return Response(status=status.HTTP_201_CREATED) + +class UserFollowersView(MixinAuthBag, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet): + ''' + Сохранение и показ подписок для пользователей + ''' + parser_classes = (JSONParser, ) + def update(self, request, *args, **kwargs): + request.data['user'] = request.user.id + user = self.get_queryset() + if not user: + return super().create(request, *args, **kwargs) + return super().update(request, *args, **kwargs) + def get_object(self): + user = UserFollowers.objects.get_or_create(user__id=self.request.user.id) + return user + def get_serializer_class(self): + if self.action == 'update': + return UserFollowerUpdateSerializer + elif self.action == 'list': + return UserFollowerListSerializer + def get_queryset(self): + return UserFollowers.objects.filter(user__id=self.request.user.id) + +class MixListFollowers: + ''' + Формирование списка подписок для пользователя + ''' + def get_list_follower(self): + username = [] + list_follow = UserFollowers.objects.filter(user__id=self.request.user.id) + for follow in list_follow: + list_prof = follow.follower.filter() + for prof in list_prof: + username.append(prof.user.username) + return username + +class FollowPostsMe(MixinAuthBag, + MixParamBag, + MixListFollowers, + mixins.ListModelMixin, + viewsets.GenericViewSet): + ''' + Вывод проектов и постов в подписках + ''' + serializer_class = BagsListSerializer + postfix = ['portfolio', 'blog', ] + def get_queryset(self): + username = self.get_list_follower() + posts = [] + if self.kwargs['postfix'] == self.postfix[0]: + posts = WorkPost.PortfolioPublished.filter(author__username__in=username) + elif self.kwargs['postfix'] == self.postfix[1]: + posts = WorkPost.BlogPublished.filter(author__username__in=username) + username = self.request.query_params.get('user') + if username is not None: + posts = posts.filter(author__username=username) + return posts + +class FollowEventsMe(MixinAuthBag, + MixEventViewParams, + MixListFollowers, + mixins.ListModelMixin, + viewsets.GenericViewSet): + ''' + Вывод событий в подписках + ''' + def get_queryset(self): + queryset = TimeLine.activeted.filter(author__username__in=self.get_list_follower()) + username = self.request.query_params.get('user') + if username is not None: + queryset = queryset.filter(author__username=username) + return queryset + +class UploadFileToPost(MixinAuthBag, APIView): + '''' + Вставка изображений в пост + ''' + parser_classes = (MultiPartParser,) + def post(self, request): + try: + user = get_object_or_404(User, id=request.user.id) + size_kb = request.data.get('file').size / 1024 + if size_kb > 1024: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'file':'Размер файла превышает 1 Мб'}) + ext = "." + request.data.get('file').name.split('.')[-1] + filename = get_random_string(length=32) + ext + pathname = f'portfolio/users/{user.username}/portfolio/posts/{filename}' + bin_file = request.data.get('file').read() + dist_path = BASE_DIR / MEDIA_ROOT / filename + with open(dist_path, 'wb') as f: + f.write(bin_file) + photo_link = FireBaseStorage.get_publick_link(dist_path, pathname) + os.remove(dist_path) + return Response(status=status.HTTP_200_OK, data={'photo_link': photo_link}) + except BaseException as err: + print(err) + return Response(status=status.HTTP_400_BAD_REQUEST) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..03cbca3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,74 @@ +psycopg2-binary +aiopg +amqp +asgiref +async-timeout +attrs +autobahn +Automat +beatserver +billiard +certifi +cffi +channels +channels-postgres +charset-normalizer +click +click-didyoumean +click-plugins +click-repl +colorama +constantly +croniter +cryptography +daphne +dj-database-url +Django==4.0.5 +django-cors-headers +django-filter +django-heroku +djangochannelsrestframework +djangorestframework-simplejwt +django-simple-captcha +djangorestframework +hyperlink +idna +importlib-metadata +incremental +kombu +Markdown +msgpack +msgpack-python +Pillow +prompt-toolkit +psycopg2 +pyasn1 +pyasn1-modules +pycparser +pyOpenSSL +python-dateutil +python-dotenv +pytz +requests +service-identity +six +sqlparse +txaio +typing-extensions +tzdata +urllib3 +vine +wcwidth +whitenoise +zipp +zope.interface +docxtpl +easy-thumbnails +firebase-admin +pyTelegramBotAPI +dialogflow +martor +dialogflow==2.0.0 +google-cloud-dialogflow==2.13.0 +reportlab +svglib \ No newline at end of file diff --git a/resum.docx b/resum.docx new file mode 100644 index 0000000000000000000000000000000000000000..3683975550c01430966315f1a410a7ff8141ffca GIT binary patch literal 143753 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%0AGML!pQL9 zk-g@12Ur(~gF`4TJQxo_XXjf0W&?y0bRm5J=mHof444)0MyO91;kZE8g)890!1}Nd z5Bfn9kazShxYDN(0V#!A;6WT8UI26k0-!k1NdyN0Jx?G^&wLBPlE92Vr{g&0c_5t- zs89e&12zFiaR35J5DSGV~7B3HiAJ4;{nqJCR*r$5G4vgKteDH5{cjwus0TFfcY>O zVSptf8s@_lCjjAsXbi&2LHGc^5-rDo42T3=FFhQfiM)%pMj%MQoZ$d`22ue;3N`{f z6`;iDrKF33wnF&9dASiK1i<6psYMNu#u!KtGzJo(CXy5}#zD_7G!Eg?7N-KR4$TG_ zln}^=5dbGZctSD~@t`kA2|Qqw-UTQ}mdq zeRout0mlv*g>oAepT#SVZf#V4;1e12iiAT*O>hUygBk$E7R5Pm62btY7ihpF(@QEA ztN1ZaHdhw6Z2q?aN%iiiMp1X@@aA`hwy zBtOuQZNg~P{1A#?XwEB5|3m>WUMR%q1VRo4<6s1^AT#6?9>NJ|oG?^5prLxgVhCWm zi4`~qNCQ|xKycUj8b*MOMKlh9M3JmmU_k&Q(Kn)R3CS`agCGV5i*gA~hl&NKf&dY$ z3h6|^gT;XcZ^Ow1DFt#tZet+yQ3Vu&{-3lU0`{WMPS9rq+>|~W5*iQZ7X*j^IzizR z6*wHAWl@n5f;|uok%S^0@kSIF85IQ~2m+u5h+t$0Q-T15De$Ie6*~0jk`Rzfpp+3Y z5Ogp)PlGcM5`JMe*o|&ah%l5zPzZ5EScRacID{pJI~D-tB6RS4wWg;lq6okP6t}%$=C%m7M$-YdxKDpitlGfPstqMD}pzFI`W@OJPx8mh!DHy~d2rseMNW zix+19&l&QXph>sW#k2HQHo$@=-l@|?i~|#Nt^Q8VjKj{4F?qiC%DeD7crLj+d(Dr% zpZl%uzuUqpg<}=p>j&2lIrhw3@H#2gb)oq`)tG;9_o6+)h6_f%K8%|if36p{wfN8B zbaXI?t*hX{J!yY?ne3{x9W65NP4#gj6v*KNN{%r=^{&G%c#JrR^!@ z6tRzP?XqY!Cr)iJ-WZarW<$dbVt2WEryX#avmI?#zF{zE{i^Evfm>2>Tll~K>a6&(QtFPGC->tqQ^)w# zO=%wbF(0}Z3oN(L8s{DLFZdF6iziG4Q&|1Pq>#+iov!8c?ka5+KP@`0{-r*@357#M z-^{C8d+~j41^IMu&JSN0Vm!Fjc#u6M)aBX4vMneS8gnPyteT#@5Blf9@Mov)ni=XD z{G55}kER#|+wnzUeu@ZYg;=?`?N4!4oo(O+rj7mFC3zQ&C%-Wy9_;w*s^I?&|NG5i zzSAK$Z6SuOJovP>Ry{lRHy#40pG)29@4D3`)ik$c@ zHy_h1(L4vmDQ%kJk@WtmXp7QHg$hksN^s*?S2fPqEXFi3l2mT#;?Nx|gsn-htqL)r zFW3>>-PqHmglXEXmlp%?yswR5GyY|!y5=pXyUBh7*hQY&ydPEfpA5H&05B6pE9j2H<}r` zS=pHM$IeT=HH5hF%lt1BYd+vzbF=(crcdj&m7Xz5)g7ke zqeJY^D9;kft0@EJ=~mWdYcXHk27a4}{B>q2xymo?m+HZjC`~H;wa%cl|1$%H1c^4O z#hMV9P)Sf#+!0J)8Fi4Rh{6N06^dW&%2Ep{iuOdlso|Goj{jFmi*crzEXvy$1Fzj` zz`FZ?KZ#Rl{#tXRJ@8{{u6_W7`e`aC4u^;9wH)w={uoMaQzMDW13#2*|8cI3>&R^T zhVn~)ncXpyzwGb2ZBorTbpKeOChMjLXD>-~rvI^g25xcR^lYwcWbm)T(Bpd1pZ~n@ z{%u^LGwEtLgu}uvx4ZbW#3&gw|9^;+*qnNuvAAoCT#64v^nfR@ge( zdr0KbpYA_wW+rR7DQ?_v|1h^KyYS+io%dhwY@lMa{LKxI>qxfNUv79V_0CY-#llCl z|5!2J-4thPb*D{^|5cd(3wJX=&rIH1v!AawR>3W)i7ecYJz|3!IxD9P(Y%a*t@9t; z9slEF_~*|5N~3$*RK?f5a)FinrCfn~6(x!e_$%DOGR;IB5t6IkOS?7v_WySaKa~xNo8)hmr6T|DbyXUP=)rXMk(=>%X z`ieBo4EtE@Hc^)OMM+!wn|aEmD_+m9vydiH3{cdOII9YNixc}szdCocv8c;2Kr+lAm>CgDxC9uDH8mVZ4 zeRlSy(<~_Sr(FVPzSZgt7U@aBv_A{=Gj|S=h0@u+-K$+wqy5FQ=6!2V3XH+w=?CA|Hl=-JN2Jt0dXLp71dx zFfS-DAHVcy1b1?S0Y5(6D$DE$+H@)*Pk{mdsPcc8$w%b=BhX@|{e4jW$3lNfsK2D` z{}UGaS;Xd(?*9;JnmXHA8)o1-;;)5mO8iTS>258Gt7jeEdF;>SW7de`HYEOI=I<)c z2>0~+#QawGeDA+6cjJlqvs3?|6!zA#4T{gq4MOz|{Bz=)`nS3V7olIi+@xLH^8PGr zU!%A|!QAJ8X@ci?*l=6H++b0GbCE1Y6wZqOf%WUJcPAc|Ma^eToc6(eK*GbuAarYB zVxmVDlF1!ZaNr2x&-+%ki zl=wdYQ>SKr?wpPFKZDaDKn;3QjbIo6zkny;s0~k)5n=*8CP(iuq=Q8Oj{=XW;Tb)` zgu%0L^q3jF0SnLsXf1+`(c931C*z1MLIlmX;8jiVaxV@dJV%FT+Cl)8K$tCrM?jC( z;YC)^0)Pem2|FEZ0q7k1bAAL0n8$9Q=h`riP;UUn0KgY&14w|rFOvCpP5%5|M1%rC z57g->K#&ap6?6a~Y5_6~A(ZGX7zbd|LN8(ty#RnfFa~HLC>latpnU;k0U(?O3E@3j zsD&UGU=(=Qod9$|8(t8Hw4^`9hZaEcP%jb{tb?!|z!Z2OJHijJJUr5;BUWJGFYk7Q z_tw!jMb-g70ptRi04xj-`~g!k-vSpc_+lP-G(Qs%0`%2z1r6XOZvfo^Iq1+NbX`~l zEjlOq3_ADU_e5|GKq7!k=XaQI!|UY`;se435F>!^10CqDrQ<~CFeoBIhyca`S$uvR z83@KfK4b#|A_1X<<$#x(0mcah28^O1fD-|p1Th9E5x5Tx0WJuQgJq!??M@E|Kq7(z z=!ir*3xW zLT>?tXhWt3gcAxz^kzDGtkR<#)`D9=4%iH`1{9(Xg1sQ@frL1RcdVgVXeEe@M5U(+ zw82*JE5B0~aH4+#%H=(sQFMrMKm z`gHI#y+uco%%l2X6j*>TNOnL-(K8xd=iQ51$V@N?S^&yItm%*%1VjOD0M?;zh(KBB z9i?a#KqfGTq8Z`_ghBC+;H^k@$WFux#^KF)D0CqvkQotfXkKH;GWs~cUyz$1DpAtX zW07t;XraW1Hh2PrLVO^N=*S)@5J&(Rg_qKyTmt_9wrQb7&nYyDzt&f5#xFcZB$ zcD{v-Mjr=LAe|vS!EZ>(`IJR_|DMSI=moFMrGf$1I2jnw_urmxgnt_-h?8{@6vQ0V z|LkJ%=c5NaCFcKM<6XrTw(OX;Vp>fm~`-TY8EY`24AKQt$C^*hdZ^=IUjc+`E zTB9AG6-}+ZqLN^{Q!Vs-SZ4I<>^a){; zdS3p=fVs(1H&e%&^Rv^3=B9e*tZa%rl%LJaXpQX@e;+zMIQ=*zoZPImD}7SKs-fDa zDF=1(DdwzWhmYKoVnni4x0TF1K5^38qvW)mxVr+*+K^AHxguZVGv1+$ZD8DQay-@Z zqx*z=4|`$XoYi}_l6=~j%RpO#h?sUz!<>HipqFzV%k(2%ttP)C;cYd8l=^48moczg z8UMIP^CSJ>F>iJ~?a=)J*?7D&HHYnDp!+oF!pI52Oo*8_R}9p6HhsUB-#lo|}HB=2H2QWdl)?B4KS%bcbsWWxr)4uVHYwIE}X4Um<8nDkoo~_Uu zpk!k|ETSA-l!8kyY1rr0#bjhVlJC)W6oR$QcX~Hz|_zDrB%bXw=+H`9IYcu=uriR<^!ek zU}L8;?g5Hl{7b91-&n>;e4qW;-S8cqe(i%iC7tb+PT`p8bq6VIl&qbOGDli&^p892 z&#zWGlB_K@`GtYuN1fGFW#4S?p}c~`(BE)P@mnkt-A@b3T2FMJ)F@g>WZ`;{%pJt!fP%Aeypd3R3^oA`-ArX=gI6rttW?R+}cso8gK z7)oMI(ZE*gwv;=X<}OO8F>G|khK2mXfpqEK;pRd;={5{+_AAqOFZ-KLR@;;MBRCH& z%eFDyA@Q!=-DZKER(Em#``ESNG?S!^U0-M!%A8jHB^N)g)*dNT8Lo6Jd)j= z7g;|v`8gEx_Sn|F3kx@h;Gx*cv)$r#HHn`YJaodAvF#g?tsp~bKx{-QW~>_wBJA`v zKkQ;KPYaul8@FjYp`}8sT!ZuaIydK|=Zi+L>KdwCZ5v$2yRi{lGxyb2ABg5;mWt0O zOy}=V@{;+jYb^Gxkwt(**fh4=*lN8~*c{f}iWC?4Ju6Giqv?ZK*h;3s!9(f|?vt+M z^G)jaYTJ*}8cfKd>z6a4fAy0=z$Ac}l*vB$si3qjvr5F^{C?CFB5OX@5=cw?fVLEAGbU@;??-KW~7wk7+M7>QLBs z-YhR7dIQZU@lo!q4UMQ0{r*G;+0NkU-sl?tj5{gQkE<14x#p5?oI2j*I?xiOzoVCL zyjbj;CB9uoTZc3-%@Vde1GC@KITlTZBs#Dc32h(81;X6^C%-Qayax+IeoF)!8tr5Z4 zPE&$1nm>f@W65^cl6&9Zr!8dn54z-o_XzQNejOV;-0kwNSt)FvNA;3r-9bL1WzYNT zwY@$&weDM%ZD6{?t7$TwrXdm<=Ws!RVBJ|!9oQ6>m@$y+Qr7?Dr1J$K9h(Ps64srm z)gDMGn5}I7&T38x7|bf^AR~P4;K=8<$v+@&h}ugFIwuJ6ho-kFZrfl2l`v3XaG{Zm`a zz%A3a=N&So6?t6m9F82x>&-M;=hVC;;o-O3V<`hiYgZL^9es?$>|#D;{-NVQx@NO@ zAL-QzS-C;xxD)2M^V0>z4m7T7J(koZEiPo_Ew``B;?0`5?a!xZwj%2C^|7x#H9q$4!JXfD zD?@Q0TNptIxWlZj! zPN?>I(rLe;-Ivu&@1r!e-Nmjg)~V}g;(_6EOqkH{HTSQD2K^kT5^uS8j2>(mPY(K4 zKjTA9_?Uk6p2ysVy~Cphe2KNXsdWea*Iv!>*tsP$HZ@M#V&he*!t*X+wYrB>TJH|w zBe6S~U$K47K31^Xc==7Y$@rHU&)b@ZZmddFTOL?v(^+r8WLy5p_N@&vnx zQ^OVP_v5}NZF$S~$vdg}Si7IB5%sCU*|XWv)nJ#zSVg2`jDO z-+x__67aA#RL6Pk)m)F$TqC%=rkX~v0qbsjwaHYBbt9=Gvy?Jb2=2Wbf$H$_&O z`G#|hN?_EO)(rDhTV9Tf#@SF$vhukB&wv~OZ(D|e*8$_uW=phB*62x~^6b^p3#Ze z_F`2LJ6*}B(GvR~k9m7OpXAxuvbHzIioEk9dvZkVbr;wB-1~muRlO3bzWQye70Wc} z_>?utlX+qwy^~GhZAe#onTVV|<$7X%z1Rb9(u;jNtjulnx)L_E6f{&^Wy)b`I;gsR z++VhtsXrvKI0Ae48Uz=7%y$h-VUWxgU@o$kPG+sSl#v^$rZ2&dL)>xsf^`dqMUc;K0kGa_Vni4Cm4#F zGxzY9Hv6X14Gy<6HIfQ^BAQIRzVDy#O4I+59L&^`h4EaUzEZPjf?&-qoCqhTpBl>!h@5ujllja-ic? zg*tM?RS628PObZi;MQi-yC$BR>$BUGhi|Mtuw^BM{f%9qb!U%C^_*{qQdx@+c7JRC zTnp9ta&-D(CQ7xq@oR!i-rx{Vfo^7{H)+x|-j17G>>8F{8JNGFvT+w?xVR`+UA82m zx%jDZ%%&moOVO>J@^5z}E-P7|{*g;d@U|t|@ml3=zvbtgh%gWK8#oZ0Q$e;cli2oH^JL}M z$(e(jbi!ykr&8RU+&9Wf1vU=;IHr0{BCcJHGwjZ{Yg?RK8FstqP%7@Vw=hj~DOr>s z|7?A^iF5a9jF;xVOW5AxqHEC`?0U#U4G)E0k?U8Djp-Rhu9z?xOQ;a!D(qkR!eL`v z&4J#gIt$r0-DJt0>`%<+Hb#f&9P$eN%4E)aiIVp2;sy8Tdo`24NO`W$c##_@U+S?W zmqjj=aas6B7WM>5kBJejW~RM^uf9H70{;^8m#eSMI#a`Tw1pj6M|pYNa=T{81JYUT zh^iQ8u`07NE1_xpZ+em&sY6jr-v=n^E0~OfU&d_>^gdfM#r008)3d%vhKwFD<9SR3eEyD`R;nR4(k@2Og~K{ETyQaQ6#sib4L2+a+m&rGV* z$*+&J`{|5NnXq&Ajpa(4vGyP3e2TB zps&dW#cx>OmnfkX1hHWm*OgU>bD#_oncCdHoWZxS) zdin~f5+{^cmn^x8ny`Flbo>ahn0KvgZX+P&RR+US%n_BQGH#;T_` zPu31_L~c2NDD6yITaMwTEz4mgXJTS4r_Z_W8P#y#;pxckiVCbGF2{^dW7#l?~jyCf?y_UbtaL?AF8* z)K+~#2`=pNQ`gAp6;m0B`jr1M^?o(mO6DAo9UCucxqKEp^7>2;zFF|*>i3b)zOL_S z62N~WQG6n<-X~L)&6%c>P1bA};e&H+8b1+Tw@g5E;6?K>Hx|13r?wwD)*UIci9g-D`SxmMS;_UgSNWFVo1npdHkei;7Rtn=HI;6Usciat&N~* zZHO8>B&OB-)UM=-%!VkFEoScz>J;*CKc>^1rge%pqWJZFUfQzdxq0&M#g_*?E8zRV zo0NE0#lJ4xki4G30_QcSWHz7>r?uv-wuZ7>jYz~~si;o=z;e0Vz#!hKSCt_W6}$=X zP8_YE7}=(o8jkn?z6jMwL!T&`K#vmZjWy+A?{WiW&b^Fyx$Nzjz!CKWGmKS2ayRZ!snhK6v|^^dD{iQeu_v z_(Zk;1$)-0{eXgz7N&bhf|Ylz@pHoTt@f0)Gs{_TrKLMQ?0y@(3Z1p-KDDmC)U3Z;x&mWhStTlVj zo;`a{=K1XXJkKyw02$)w9A<>NPvDySZ}3l88y;hB0z-@H$zxsizvd|_U`BWCruwno z2bD@Xw~~40b@oMdi?90@erYaZ!R3}U(`*-}F(&Xi4`ulAZcfc+!#}G|KEWrN$wqsP z)%(9ty2=gIKWapE59OMBjAbUXR9|b(Ja|a>bZ@c?LVK!QuXH)7%0IY7Ci>%Ew=dIV zf(6v@Zn0i-2lpWHpO$^~Ei{Iy;NPcI8MBXjlXE;>^~@MM^^Lj)Lz92K@%u7(-`Eu}1Z8%)cv<(So3uR9G@b1aMG4mTDCXdPG z_eGc$*!9b6+flj)D7nl(F~~j6pi(BLEqndL{dpxc+q$hZ{5|GJ@ap54Uy8yiOKe)i z>}T4~!o28o3gQ-aJDypK5PdvJmE$RR!}pCp&oZQ;$gOT*;QBogPh9IuIQ)(-#nGKO zInid?Qz`GT$8FtM8#=Qi_a(N^(q>v;VT^AltirNGO1Pc_ykdUQm}wChXuGC-&KwCd z*S*%7&LGzMorap|lSPYHKUhfxR2u#oYFfWL^IfKfSCcR^8DvLvjlQt@6Z`Ho?v9I1 zcJ`YRo+ihLlxn^Q4Loetyr&Aay3B7L5>-$@;LR5S4{Im9F!x|me$|m$mIoWI{D^QKt|HU7}3Gi*F0P>bNhaVY;PsPy|l4! zQEoyLP8UulxOjly*uOx$e7}E1B?M_0765&KzKwFwB`zqFR}8&g_53RmqfEc!iiTOo zWaNUy1ZO=&xR2El%2f9xzp+T%SL>j0+J`0j_u4^};yNq6>r!v#rW`X`W&rX|&RMjW zn!V6%Mdzd-YQcGtiQ%WtiIIA&X{2;)C? zhrT??YU7BxIKLY#kAN(ih8^}kwrdn|C}VJ)q5b2)+Wa6Opl;o*Qeo!yWt!0cDU10J zWbj@8o)D{dr3XKk`e^(q;-<+9$Tn+D-ntI9t~e&P28P!1khI<2%`&0iEPWA6DQtK# z_>y6x$?K<;ZLROKn09LbBy9k9tCo8Dqtb`#%nE*N=}}c>{`^4CA*118hSOR!^YVao z+8+G~33nyE4}a5L(A-f5WX&8-s4c65TXHo-Yxj|l3$pr;bKHmqT1q<#j7M%K-yRVyT^Ex^EHz9&bwE@$DXQ)2$G)+ z)Mj2}SD2-j1<8Z8m^;T3QeV)LyjU)?eo01=#xr>-m~71v(eUPV97H0|P&cGkl!P2n zexxwgdB;LMA_nkPFu2S9x&&NNzO>@uHZ+DKmh4GBzq+UWe?)`b@rs|ho}J7u%L#uu?4 z(e+>YK50lhlW>&O3GNsp+<^)10BMmU&meXRB2Q$&nykgmOJJ08B(Z843uvgp(U9n% z_T^S8`Yfn9X3&#!1>EA#%=kAQ*9|e(AA$l8aOCyRJ>&Tmp7NN!p*&5SY5U6INfrIT zqtkbPGclacIq_SGBm^RC98%&~{hGA*c87!^BCYyoLCs9EY}}Mzs$>*f`$r5$d?oyr z_fo&qQ_p+iCj2ord{?L(M`19~3Ehd`lKRj7VOJY_AeC)LyEUhQ({h*Asn&W^A6?d^ zxM|nWRv7bcrbt+tMhc(dqqtGm*jag>*e3w5^}zn?MvsFN9dMf?9BD8*ks32Nd7V^z zzaT)#YrEgRL+5!|*xgKK5CP`;_HZdoad`Wt1mJGwAfXLq)TgWKyFMAq(5FnQ|B`W; z*XGJQ@7~S4Zpz%pju^8v%*mMeQ3A>55Aprs!hf>4t4o3I7pN|(X(G_0-%7fx3vFM0 zA?`jzc#5;ly9t;Z?JEACz-o8MC9JUo7N)VuD%#&(wv}9h1MGS_Ccv5o`@5*Do z7b3a&9S(mxVa!%{DY6^mrzg5m92v~scR4*xzxQ1RC?4w_cc#UURpdSoS@0UHj}&!b zQhqH^#8$WBkSLOKqc5ti{cddcAGbvV`6lbS z2eJB>Gow-T57J&*lJ(S_E8*SOidewoj-fiwCPhDAonu~)DpJo7?b4EX(Mg2}W?GM0 zPNfKDj;^Wd5*57inp>Ss=+owdK(XaFFDP~Eoj_ICo9v(J#5|5$DWO>}1}B{=-yCv& zN&}`tc~1sM9k+>VaM!hc<05~;{}miYUgNuL@wWDPT!LYe9;w#mwHr6z`OPwuP7hNB2IyW z93PQ4CQf62Vbx?g74^j>yoAK*@RYhAA3J`zCK;OE#^S(tLO*({A!ed)1ZJL z7~1+Mp9~jOr@5pURhl{+u9XU8q2XX6p?DHDiHr^wHip+E*( z15z1}ggGPBDJMD&zhpT4+xeM1LT9sqRu&o$16i%Nv&(p0k5y)Gt{gq0he*SM;-&>5 zq@5ziVm$Qk5!aMkSSg`YYM(Kaq4FvcMVDlH12e(DuF$E#3JrdQ8mS$d=5&;yXf(A- zE`DagMce3pMny?sI?8nPe6Kk~!ho-xOEyuZ?GZqgEzE(~u;6s%lHAb1TW8!LB#DL1 zU~gtH+H2v4!Z9iueqiv7PrU!T{MFc<162KsP|7!Mu0=TUB8nb^KF83793lRnsI^n% z4WnYrZsBDjTDZVgZgm`YP66@Abocb6nm3l< zUH(a%8#c}9lD=r zygF8GhWJ5YbImjlpTnA$@vpA%_3h^}fR5VQBtAG<47Hu+BzPkpS&fC3(u}P0t-e+h z-FSu<(WY(VbGxt;*3QwUF?ng>JPzEqw7 z4QR@&FKtqcknD{L(o+H1gC(fvBe|UhJ7|8wYucgKdQMHg_xgl?(kHJWc9<-O$EZ~o zY1@82{ne_AcDMj>+bzoX_aAh0XdQG?vLr3y;YP<2>~3ZSX|!5+zDZW}ooVVc$G5fZ zQlQ(Ntm88BN9s1$xKH2HyH^!d_#9Q78Jffr9q?6Z(t|G9&yME<=iN@6DAN zQYvKL=8PjI=JPG-i|jM;Chdj>1DOR9r&Php@V0#CuU*;HAlA zD-Mv)#ki-czo5O^wBd#;Mms|-%-xa-xJ7mJIERAJZtUR3#m7bJ#QG&&a)EIEP0QUh zDsi$8&cHv7sO+4FODc;eecy7We`Q>JvJ_jc@e9hZ7U@QXR%ftZ0L_AQXJY;JNlQ^r zH2d`4<7@Hhx$O$Whg0oONwX_mLkUw(aHH$|%TJB&6cDTlLHm&14gTwu!_n?Z^J41k zlH^qGn06(xX7|fQzlymh8?JC0>{eaKzC%gaK{=~Q4x@aQ(P#*(dG|{KGj|kewBDZ- zwLVeZlBdPcn?yPZ_$OuF`B$r1zK{~XHq%A^!Ao2R8XAGQFs@yh^__@VeqNFbh1A&7 z@*D6aDd%cJ<67K~>fR?3EySj<7CK&;Wo@=bqhEZx;mJc^I=p?M!o7Hhx@{2~s#n47 zt%B$dM6vT{Fg)Yk72AiTFxKuNf3Xh>JR+ z8HlK)>mwB@ZAW+REZNU9CCrorY9dJ-kZ13RBufhXjLc0BKGF})C)&?t`>S=(AY#JN z;}&<6jTun^v(1B?{lPUk85-J-k8G6hE21QlGi2E2f%cb8FA%#_7i3EnG0uv2+iv3& z6f|ZSL27(`TfV*N!K@ug?17UJ&pLI9D8WF~CmVI0uZJp>aO=#39(xgLNQ-QU+9d;pwI0j4?LJ6l! zH=rX*t_oXqwvYNhKPAm0&K#wx4KbV5C%e+*c`8JT4&N$jQzEa;q{2^!KT!Eape1(x z`K@SP_CaRD)x?u1O6cforA|P$AW_8RTm(vVwL5o((kbSN0wop zD8YL&;R(?P4mw^;)sB8SLC0{4NsWE9$R|?%4aOM?h4O!#s`tCu@%`e}aFmSxpT#Yo zcGIr8`y{llJZZBx2Q;dEeM=Sd_lcY*3eJi|PO(?JX-AhO2@Aio;&+3K9*uGbAw&}Y zVv+kH_$8Z(rxIp*fG9rmR7h6+mlARo5KOGrsh6_r2#p0K?0Vh8HIi-)wos?FL$6V7 zU3!u3c7YE6A8s4YSQ5JhN~cQK>KA_6>4twlKSK`J`I#Of|>=-7TOR6|SuV<^cd? z4`Kd;o!S0@FbPTU>FIHTQp?FAX}8`pd&ZLM=H9jKrUUXFms*2Q-vtZ5aNaS?A9+9fHU!iih~2z1Zf%b zZ|?BC0nDO|61Z;{2{KoTKRsG&g)j$I#qDlrz!z8BsbfBb2E+2+mq=@1swB((og=E+Ir9gEQ{1ypYbEzTSgbufa^G{G*k%n(2D5{g z23y^Ez}b@Jo-KA0#(f>&=8I=@goGpjb!+xx0>&@DBFg-b-g&==2c&F*jDQt7*aWdi zuF=5f;K#@0@8LC?FjMc|tg|7L0t|_vozwQc+IQ0$-eo9i$t0+M!f2-(0#;gob85f~ zpi1duJHum>O~vLuit(@~r|C}!rHTe8yXYOZZXP{x6jU5HGvfFjj(2)xQCfErctJAc zNIC(3y5zY*JnW@l@BQ#1Es7b_nzp;arxfII4_;&Z(qB^fsCN3*@=g}p_Qel3m1eK; z6=iJ(TbuD|43SJ=IS!QaLQ{(Va604lH9-XlL%ohlUpVvWQxtWFyd;M;gGd!*a*=3& zcy%0>4LCgN5HZx9?50gq{Tff00a3IN?wt~L-SMrpbJE0j>#`Hy5zrY;XHNIZi;gwZ z|2|sol{4MrpyM6hSbGF3_=o9xg(Z0BHDecy@+6w%zpK45!tx-c&x?Pwt8k8?j#Ya0i-o46ct z`B2hs{1j0@#lS5Jl(s?}0lAZ4K3^3~N zRPh{2sel-hO}gi_!V0H5Mz;52ktRVv5OqA)P|p#h;THL7Vwqg|TE%F}(cYoMOZ<3v zpc!ZM8;8U0GEIT%sI)XZgM0N*l5>7?`Q2F223y2V>+}*STP9&olR|_b4_{anH`Jr} zh$&3YUB~1AgGh|@D@`TScCzDT!;|${pZ3`*BL**~&ZMbu$~kFB2byo4okjXT{XNsg zQCkgL(+0P~-&VcbjhUWX;jm?g@bI!h<1BU_?C<6@Tm1;aC!gd`@%c#AsIoU@KA&(Z zLpD*lcOFUgppp`Vnf=fn^ws*?1KP*w7eMw5F7bH{4Dh!@JJ+L|N|3C} zerV1NJM_+Kob_%JO1@)Us*wzxlebAw^zCGYwK`! zwgISvjJ{l^j7z1)$>B{za^_7gVfKawp6y!$O&9jzW-%C@l)2$!RlOcS#0Tknt5=SA=D--r)1zv<=vzeQ&NqD|zy~I`9;MOA;<< z^c&~5F1P2Zc01-m=H4U70}?VIqmLxPDGyi*KXxzqYb+jY+ehCogPCs`5ofiifTPe? z8&If8UXpposy4#7Miz|`b8wWc9y+gbQbs(k1iujbdr<*K&eMM_ zo257D;L!pcF*r!tOPK1MdDN$*!s$)21C@IZB!rP4lCn{=6o#bo0O9k>oZi6(HD~hx{9bwgdqh?EA_zX z0}M(Rs%;d-HCtf9G9S!?0LF<&8kl{|!m+N8p24g&R?ChHjpc=#XkCPYLMa zRlz0zAN?K9x+p9Yd>kVS$wk?xR9vkFOO8M3JI$Nd1Xt)kOM_l>O&GK+9DO8U#S zZbAwpVu9+Ir?V*4gGbRQ96+X9M~MVcGw3+JpR5yAxuA=CQ&D|YN9eO-qPYW~+*1-B z&@t%dls9{h*R-01UvZ+&?@I1q(D88<{ykCE-Q7uwI*(V%`okf)9?oOWUt^~e?a6d; z_Y{w}wbqtLCniD6iZ}dq^NI_>MO_siszR_gf!+ks}$#mE*CKpeK=;sXnmG zk4cAhF7kscwpZnMbJO-Zi!$ZA`)j8xP>`K80D5>)6BH1$oyrk{t(SI`s_y4m@WJRb ziB=YRD@xYV$+*qO5T$ks{odA2Ey0K-XFp7HNCD6~b-)#rsYpCVA@5ubMXF||m!i-R zXMvFI?#|vkDN>&&Q}+oDhEnaDy{Mh9Bg^r1eKpxiByMpj-vVUL@r>Vl=e4tbZF}9=;CZ$?C_Mh;l3eIJxUsI={Q#!F*; z6_?X2{tKY;UliHjozcbrnS)yItnd&HFTIkG8ytQDr3<=Thu%;+Jf(#`BAi4V?$8o8 z9J^BDpDW#dCP%>M&0pf^i-nU|d`tjl=2a$u7=DTwZx<(c8nql>O0SG;fGZxZ(Yj0= znd&~)DoGO+U%tl)F5l=XHCmN}u8}ocQh7YO!EvlCpLP0sq@VV~b4dc{$nHzp58J3L zL6}?~Pi+}>={nh+fp6f#%sWI9;qxXgelo0j3UiI^E3&o}=pr`W$I41wh?I-$YlUHB zP(QpArdWY+HAk-FAn2bw$C=es*8v|LaMOGCw;z(PE{$NMcE9YG*Nh}VNt!U2Kfq1YPv6n;q!hooRh z9{a>;mfH;rF8MWu+$JODntkH;Qmm?qskhVWUxFZt@tAF``l4N35bQmF@%6wWg zGD2&w^jLp-`rJSv51fHT5$f(q#rC6iXNX^!fG)j=>4x;9a1isvYu!#JCFTwQqS7+% z$}CH!oSI=r=4uaO#PNqSDUT~7_Y9-*@l1yu+Le8=n~1rf&Up6W!iHHAL&JQ_$ameZRs zO0Z12wQn}+Y9MeRgE%u8G^=hj4;s&Z3b`S(eVtIMc`V^SQvfrsA7GYj){WGccEWs9 zuU>^cwYR(c*4`u^_G;W^WaJ;Oipq!7tPJ@|k}VU8!x&QB43jI0hYp%o-98fAr|HX; z&c|dC;}ggbY3>6~HtMk+X|Y%oZ1h*9<=rOMjPblT_t7$D1|Q|E78zCwv4)izuN&kN zqgG+Ju_B*@hjN;j1*Qwy^_eRPvww8v5V5xFOd`S!TI^lrkQ^`9qS#3Ny1x*-Z>El( zEXYG<`b)(Wc(L0=lwJ$iC|ZcPZ= zcWa(vZGqkET&;ZU$N8V7HS=u%x;g_>BL!buszzBpA!B8?;EDE@*+@)+fkiILZ-z~P zuEfuYr{3t$xZ8rZ6Q7xYm)BCO?$kucTq*1tTF}jv`cl9ExFZJBLQ!wY1l)hXI5?m) zoz_Fnj(tBueprR7FINj6Ff%#y5tUG*ITr7GWM#z@xEy!n{8~-ZWd`tCff26%_ zkR~W>swEVAkolYoIYKI%B<5#jijw`e z-1w@~M&uPuQ0P5lp8=hGAL6HOHxqTbdmdwn;n(oiFq^)dZ(e*$X>=)x=|!UYPhN8vZNx=kJ4A6p0Ui{w54vBTdqayvxG5 z#kjz8g$X~@&__*@hjp(cqyMVxjatm7-AnsR7&1Bd1%stYy;B5wbEq;PP3@A{W_w2QfC@ zR*xVtC`?%ARiJ5_Gw-}XJozT!zV0pug)@$y>^y<#OT}A({(7BK@jJRBpE3GOsC2J> z8t53Z7F@VZyWXv|5a%9Amf8(><+RKIZd14g7Y;=*41Q()ir>DgL?T$eGNYtpng3r* zC;m5zJ<}!N=Ng3xS4dvL&Qg^Gf@H_S;TA1P8+plBJY2U#PsyczVi6jYOSwONazJZ4 zvH6)t3A#POV+=gm+G~ZLe!)+T(vU2Dp8(BB8jF4(xkN%y^1U{>ivJ-z5w1u|5@?#D z+|U-=ZHkbS<4K8c5g11(iE#s z()`#W*Z(r|6fixy=4m(Yd2y0Nk#Wm%5?pr>7vBE6-*-?$H+9O~&iG*o6Y>3NVyl`G z%8-(NqdZ*T=a?6nT9i}WaTk;5wsVT&c+%{M^@wKCbMmUY)X zNN}PYO?xVM*B8?HYt2YqIM$!?^Kpx9!2s4(g3bWyiSGo|#>J(8mQUM-gjxUTOmZqz>w}I-%Q2|$f z4=N6&S<)avmqwJn40cy@iI;s@=YRg#TX!i&pmAs9lnxQ9wgG4xWEkqzni?FlS$$FX zKsKYA%S-OW;0txMOv7{fYm51V2j4h52n1AlV4a=jy6;CM z)+$6*HGJp`kwUaaXiUA}Si7}^rbhqP>v8ES5q`d#x+=luu109>*6DnP9DEn4zEcO} zySh4g+BE4zDyn}uwx-JQDVb{3g3t0*4uuX*KaYdAmh(|o3L0}l^x(Wf@@YhajE2kS z_?AE6#lhbmIe4A4<{#9Do867sa&l0tZicZB2sDfs`YeN*A_qxtC2KH)@5C^?x*>!K zPcPm7d<)EM8u_AN6#Cr685Cf$*w1O~!~aK!4i8a>UX`QXyeul~f9JOvow>*0A}LxacM6@?B+S7iD3dBUfMaq)XeO9YFGJPNcFNAy@0!w?~cN6 z{%8C1L-%8U(mu*!bh)JDfpSEL-}z8{E!)V{>&A^o>d&b)!zP04DU{jsl6zMv`O7sw zb(~;BKyOqWkn z3fd)+9#BrFH5weNuCrzd{UCu3t|**WXO*k->N2_a@{>1exmz3hcMpSH_v}9e%&lV- z<4Vq%NAd<*sqxkz4E6zRE~dv=9nGMOJ^?%@l9n_nQQ&8MtRCK^?c zOwtA}e1ztz{1Mse`Y~9+MQn%b*}p|pD?9(hs)zNx`eKbP_($E7ORODZ+x|Aiw{yer zh4ZzSNZhpOK|(ltnpM>%lkQs&*m9D`Z<5pr8KJ84k`$0iE5DTR+#)0rdZFf8AX69U zd}Hov$ICSVQp|}EY4C(Tk=_5pMX5AsL<2|j7UlOZ`{sNgfI4tO!^;vR_#gA1=ejMG zz6ZPHD!Tu}84?ThtX1{WB|Y;4{Lb{3>*)c*-r@ao#n_~B-=<;{rIK4u3|Y@DLdR6^ zTXYboMsl{~`RG{CH!@quSrNXCNW)2g))?kUiTe!KEk;az7(KY6Mt1I-_;dGdHt^b5 zg?Ax1^~Sj#HT(U>6Rnc=v{OBn^O05m5azz#(U6JRk0C#Aq)`4Vy}03(y4p1!vU4ZF z=I@9SZfvptV({JgEbTiimqB+f&#&P=90(h&O}lXBiIDvmYeO-n>D}e?N{x+KKY^9? z3ftTmdXhy2mB;~PJPi)R!Q(k%T*vylJMDE(zm$bJgCxnCqV)L(M5m!>}tSx5< z)&niOtHYHW$1TDN$Jzsm3dcHz4-Mbk;Cip6H+l2Aq&niIb?)^`oc!vxhK3lX=#IMU zb2PtM)X0?I)?M`=%y^>tbw>glR*a_tiu)Dzf|LeU{1Ruh#}pPPy&D>r>)ZUxpU6H` zcnk>Kp6g1O^BV}T`n)_+JP#K)d_ z@xxKW1066c9x=fLx}W&a71#s6 zU{&c0t$5J7Bozi*S^1rz2x(|}aBq3#H^zDf{Fv(SB7bi@r^eh|5&*2FjBnqq>WJi! z^C@`&+U`cyaMJiL;sz3MPjHciq`%Gf4n1hcB26={jV!E=P0b~-M-Jy4Wz=^G$T1YC z?8$g0nD_|lyj%8M;4WJ|lUnQrSdEuIU@sa-OK+;MeJN`Q7nk<4@BHnBQgn3N?vDA2 z(}VO+{%=-<_Fk|~^}`WNo}C+;3@jZI&Xu}g4(ZR>+S)=Adno&RP~vD%77JSWV& zW@Pa@-K|K2V61!t^r4VE18>0*TSJ$iECbKqLIbOM3vb2C9TNl1u1q=KxvK~TX{cm= z;$#cNqD2JjNvwFq7##?-n_VbFZVm~TSkaAR7QX`=2Fo9uDF-;jmy3|0`ev_5=csuP zeLQEE#0ZaevfE!pBn>4NQHn&jk z|Na^|MOC%siy2!bb7$2obH9jgt(geF=5k`W=iRf=9E97mkJ}sbX8mpt8FwqdV~JbE zlnRHGw3bc;vl;nKh>z@$-ckx`LVel`oX9*{X`tq9%%b^-hpm709Nhu?E|;xHWv}`x zwjH@NFb`O)6VjgxSC~Sgc2$KH<`a%!zJZ*Ai^!ufJ z+C1(u*S#?xR^^5kp%?=wr1s9PYgYn~?%bEXHvy+CAihi{N#|z$8S+|D3|l1a3qhT> z@&}}yzb_GqS7$ic97G*4)CHs+ySg{#%c{S3@`+9p@O0c^AWNt=|F!~ptDTdR6E~1d z|AYu#7@xWLgM{&&#FlK9ds#+sVdAFvvT01){OBF`VGqEZz}7Gu6!0m1?8y${8TT^TAhWyT!~)@$ zZzI9x4GXltIi=DcNW>Y(oP2AqPjAS!)#3q=TT^ctERRA*BJVaH$v0dPlKsCSASVOw zI{}l@j}Pt?_vIXGyl%IB=~2}8i{O=D<|U}3Q!R61?Iso(^R=r`>K%NJyK>1igRRuE z(bzhp-PXpVXdoxOH}|Z`S>dj&4W%Ajg9wF5L7#BL1wD+aQhR=0w6bO#VtYz|RMw%X z*&U1-vff33%0x>S&k&Jz@6j0|(sJ`O5|_k*PCb|U>pd4w{E`$Ta6I3NgoC&1{L0ck z?$V%IqqZ@NQ$lTaL@bn$m4*4)JQdW5#Vj^(8wl!jEqktZ5itP0sl>_cmd)bqTF+p8prZC=nVRM@!7`Khsf4 zGc?muNmkb{mbq&%EN>iqs1?c|J0Kma&2gw;fv$fFq1blWdsEB)7d5O!MjfvQ2G*4P8vAvSg)$9Pkg?Dl)}#fMOA2RU1B|hCAFJvmS1KHNF@4LTAl;WHv! zBtqZu87ejr0rSB*Dthd?N1q(odz6{|MR8t(Q|~h{?4HOfY@zyI*zA*aR6ztWZ&p1Y z2=mVzm3Qx%o>|v4pCPJ0aDc)L3OM*JquJUqh|rjKNzDJtpCyxapU%MR1(=?Dd zXDsZp#${-3$@BUy1)0`+%!^N$s9<*bQyqQOt7pSh!IgKGp8({hqnO3#bXY1zMKRZt zhQ#I4v3$j2(p@_-sRCb^~!dKdX*nLmKi}5UhTqt%yb znJW)#jmxaoI3DQGNm6T4vwbV}RuWGtbAAZ={?H12oK_eJxTqgOz73ih(4Kc;iDQw7 zGg%38;I|$Q#$>;4GCvSEIE0bskyFo&G{Dok$B3IkI^8ZCJ?%AYL6R0KU-YY9cSLSW zQa`3<`9Dc@Wd=SS7XV<*|7&>C+!WGLvbpZeI|xD6V=nw;BeTI{UUN7|?sb`H{>big z5a(-tCJqjFY>Gu-8ebKXQIGYKA#kN-jV})=TqH$;Y8X9j;x++TYd7;=j~qR1My|kG z;`~2#P1bwz$OP=WRzQibT-rmg;?lvzN)&H%z2VZ91udo(frs0>){htjO>Ywd3NW{W zgRUPhS^&lhg&)Kpr1_~=_o0ZVNaBXLWNv4hOtOFYAswO(MzPU-7kCb*k~$z&0k8(_ zEgQ-3X1gA&Pq|U2-%e8q@Tu@#F+QAW$dS6WL{SdKhv862;cP2AvBd$n?u`4h&tcSEMC|#{ zlOo*n&oHx(?UG?4Ny?q3W?UMIaFHm+i$$fx_4i#y!;A*Pl>fu8_@+P?F16MpH0?x* z#*tyjN&Q9+%7{>OI3u^W`Rgh;-}o{|XCz0oHz5V&Sh>(&L1NK1Y9x0z{m%+!gm zf$>0778Yq5v1M|8jdm{l{1gJPEyWoD%2t9zfM8o!^G@*JE8O<(HCUNsbEz{c#-{`6 z-TU`amgN?jJ&nVe048#4B)QSEl79iC+=;yy=Nk<+t8$8;TrmA(i~ehWjM|DRpw$I? ztGe$@VIH{g@wU7h#m%(aOz%JV6o&REb9Ph6ow_rh4-y~|3!H%vcTcdyRs1s@5d%6Tq>ySc$qoS3j zVcotn669X99n>~iCMMmy*}r=7Fj7ojUeJ0>h3+X& z6xM8b=CYBTzu73-g1DF~j5if?4JK$&qrLv4Ik%>8ff&E64djrM@yBXlYhu~o!SQL+ ztMW;|V*uEN8mJrKz(ah35;5tlA}T9;)GIZ1eJ{_^N3j|awzNLgNIZ^>8d#`MIb6st z;q{zN$vIpTSMH4ch0!r1JHrze4Qe2t}O4V|# zN~JLKJ_FiTQkA$J=kSks;sws_TW9L}cKhPklF#T{q8 z!wxZo&-FkKICinX+on? zkV;}9vUbHt&SdpuD?OGNn_aLkF){RwqcS3AW#@mFS4AkUvBk+5uOf9ampLS^orT!5 z@ca6YC426Z3Aevgs1gk^pey!E_%KzgP*Xy0Q4Quns-0IwK<`-S@bamCVNUHqqyMe8 z*H1;lhA{YVnuSOMHiWZ%P|Cjs&QbsuWcg)|CWV3H@(A9U<9&!mRRM` z8NdIvpaoqAdtVw|f?CR9AuRs)$gT(u{ z`9A{BCsoIQ2=ern;`fbZ*D{G?kLX~B0n(+qPEp(qF!BdrtVI2bhdky7#W1I%U}q{< z;%$S+&0x1b$HsjE+Qei+oBGx_gg1vc5X1DHIP2%yUdx|AO1R&%oaYk_frk~)?+=KT z#FRqs5>)9-E{;Xy|1bWZI}SxM3)z0p@oD1@Z%T<9TtCacj1Mvno+ZXSsmIhzyl-0J z^sC3()*#one}6{-psq{P$G_#B=$M#pr46~DlUHJAPlNE6MqxuPP1#td?8|B!wgSc_ zPJw+-63u5rG2|C=b`ETQ)oSxeTB(BR^yu*|f^=51k;T!ZMIAE*7A0b#BY)aKoWcAo z92^HUOfx%xJCZFZEJ=3v$?3!nTzj3xTCZ6|&l|6}eyEvH+;JAX=F*DA3 z#5Cy1YD&HTYoImeWh>*fGu$0B_Tex0)86Sq8nw~iOzG2p?^#;aJXE37(s#*XrK6ij z+}`iZcESE|9!7R4liE)-c)JX@oF!`^#AMCNX^Ii>T}TYTT0%~Co*8}`tNH}edpMpf zWHDOB0n@}(4T7FM95>(poXPzMCS^`d$!pOQ^v9F?k6U_&dNEPs&cP?5*pHuQ=>MA3 ze*2s}ZiU9F=~hvc-c&9quyyRei`oN~pdl?dX7BPihyL6hmnPwe+*WUF8NdL%=V z&GAJM`*I-g#@?g;2wI-3!^*YEx|b?JLfO_|f{tX6h{<T|bk{pl>f|y7Hmx9$F>lN>2QMV`9r{V?JsPK}z1FTB^XI9DZtR|+Ik)&K7CCrn%~fk|Q6o;*Xc&1} zU0k2_<4^|+=EUG+yOgf}Ic)ih?zPFgN17A93GK{T|NO_>j&~SXxdZDWjR%>@x(tV0 zmbz{-Zy56Z7O-@5i*H|oW)}AB5l3OP#XoWTt>-mdzhz<6d!nUKi0h=44I{7qSL|6M zNBU{7r%kLqj~^6kw#`+2E$e>@JeI~P9Q?D%^xyv1mc!Q}JM&}2D(+O=l3l{Of_Hvo zzZ;_SOEGs+|8M{A55wj}wYc)hcn$jraUW@`^1Z)RSMF%o)6X;}!3z_9Q={TfV6&)u zuK3<;L;@XkOoBRJ+5<~ox=)^6m}A8?P;_uMn)pipn&sX=4WUE0=h%y8BRf~`1pPs; z(mVCdg&r(*GJ_8Kx*NS}JiHkT2DT~~{uh7nJ&xy#x)P7U?)q zV=Hr~!{5zDgjcV|Xk0UF8^^Yk-s?_`&JWR8z4}nY$uI_6)`_5lYj3k{f6-4Hmyn6R zib?77meP0+{d~tJAS?;*-}>TzFZTy!TbH}%g0?pkx6nF z`P^4;7Vs^_E|XR*1>43yxBmJO+ZF3WezhI1V$&`+B7tW+!hS#u)c3_RHY$|QCE4Sl zcQnp&9|~p2VyLYVa?sas=;p~UlfA^}yjm-_@6(ZBJ&h&OrORR6z0vbF*wvc#e&xpc zbj@-z<2`M@gmtVAYwiJ;e!mJAgSFU#!;Ws~memG%CJtM`4=()W7KDyotF0D#vML}p zxz~T3_Z^5wMGG^d|F!5+yV#$iGEdsjzn>cDO?fBuWL?TTWT@QHIQh>DfB`h|CY=9s zT7e?GrT%~3ffv1*;Q#by*yX2}%*j9BrQ4SNc`HhZ)8Zgp&fP9Bt4fZMlpRlM$n zYMhLv)759n#p&g~~XVWy~LdT6h^TRa2 z!0#uaow%wtneg;+64o)eyLaoWd+lBi?>v57RW#{NI!Xd`EmlKz`Fo6`@n=h0e27@~ z#8&M4p|Q5Kx{TvdyDuU1K@TgEUa;rWYv-hGhFi9&UN^dzC05^yCtKTWHpVZVpS=_y zv3M^=LxCzxiWO4KW8b)u`M#%#3E}0vj!b@0QL5H+d8!U2H%8uG?C`y9;lG6~S!fF! z(B;U_5B!%fJffnzNu2zImrWM{9G_<7ibcoO_+(`q+s2KAXyufG6Ay!rp9GcT{$OMO zVW?ie)t!$p9e>|5!i11|uLI8cVbxbx`nWcRXBa%-F82LhuFA*BPkU#9_YoUdx%43D zV!!fb@==_};%t`c*Dpc|N^ZK%pLYfYrA{*ICSZ~Vd^}GjSjZN2JUP{{5Rci2yP6|M zy+vaqY&tYbF_$1MB*3-JS=<&8StgnF5n^{!Ml9ytv~^7DeCkQu=G=svI!feU8^YK& z;%tq@Uy4z01x;I)d!2a}sHwC0>IA!ZN8OVFz?G(Tf_m%c-!QwgZ^XG|ai8QSe9`vJ zBH(}6Y3pp(qv~|H{QQ1~_)>c-aN4`Na+o*btjz7mby->o{alGAnFe)p4q7_oD7&o$ zW4w{L!Y%zAn+>9=iJ1nv?9>YZc%yHhZU&L;vm%WAmG7jOk&6q2MR%(arY{+ZOPfZR zS+%rm6;1u%5sVX0id4RoQv9yAc39c7SwJ+kTICY?s5oJHkwE-#FyD5*RS~Y4h{ut; z^Bsok)?YDlm{{lw2qL{$iNT4-MJgW^5d`@5{ z(J4}KA|DmYFfa%TTZ(qhUl9~dr7R1j8OYSVe;=f(Bw~OUnxe?Vea7NZkxRK}2$FXSP zaOk#6VQ3K4 z@Y1$nXtC>;KMY&CB0nJ&8r%FOwX^4qwAZHmuTP1$7u07wb+;dRZ#~&tHof!q0lgTx zL(f#?JWCe#%^2rVjE1~l;#3&dry7`&)MH$Eu(~ZFTi2`RyR>{PyppC`iAmNoOj6em2n-x1USiyQ5ueKs zgvSnd3}%d%@NARB3$iGdbeqM`h%|F@!}|0-S^N!S{1pb_{nTH>#FP|_ad3oj@Oq4h z^3}2Xs$oP4KXP?CIh9c}2ngN1EjEJdh4W=3^GO)4-2}lIBTUxqqgl+%enwd{(SeLd zr1XwSNnmarOQ==dtcgEojMriiHl?bLK(!kpj5n;uq!^FNax&jHi;gri!mNF${I`Ld zjG81eu~C~LokuK#XEWwtM)TjMlK;D@|0M@-(EZ~t&l#VzmCLVJK6(WgT{@C3CiQpj zZg`}9I>)jP%O{z?I)6{1PsNf<{0;sH%jvhvS$qm@=`1mSq$0QY_6A-tL^%0A-#a)s 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 JrHLSFxk5Fdu>ddaq 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 + + + + + + + + ``` +и есть форма на сайте 2: + ``` +
+ + + + + + +
+ ``` + +Две эти формы могут обрабатываться одним шаблоном, если его правильно настроить. +Поле - chat нужно для отправки сообщения, на него пока не обращайте внимания. +Приступим! Нажмите - Добавить шаблон. + +И, вот, пример, как может выглядеть шаблон: + Сайт: [site] + От автор: [name] + Email: [email] + Телеграм: [telegram] + Сообщение + [body][content] + +На место квадратных скобочек подставяться ваши данные, если они есть, а лишнее отсечется. Когда сформируется шаблон, он отправиться к вам в телеграмм. + +Также вы можете оформить своё сообщение с помощью разметки - Markdown. +\*Жирный\* - *Жирный* +\_Курсив\_ - _Курсив_ + +Может случиться так, что вы не сможете отправлять запросы, поэтому придется сделать следующий шаг. Для этого нажмите - Добавить сайт. В ответ нужно написать ваш хост. +Примерно так: +`https://portfolio-puzzle.web.app` + +Это нужно для того, чтобы ваш браузер мог общаться с сервером, расположенном на другом домене. + +На последнем шаге нужно будет скопировать скрытое поле и ссылку в вашу форму отправления: +** +Это поле сгенерируется автоматически ботам. Вот и все! +У вас нет ограничений на количество полей. \ No newline at end of file diff --git a/staticfiles/__init__.py b/staticfiles/__init__.py new file mode 100644 index 0000000..e69de29