commit 0e3fd06c5351bc9063c33f615e8879c3adf535ee Author: Ilya Mukhortov Date: Sun Nov 3 11:35:56 2024 +1000 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b8763 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.egg-info +*.pot +*.py[co] +*.bat +*.pyproj +*.sln +__pycache__ +.DS_Store +.idea/* + +.env +docker-compose.override.yml +celerybeat-schedule +client/package-lock.json + +volumes/* +client/build/* +client/node_modules/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1a062d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ + +FROM python:3.12.2 + +RUN apt-get update +RUN apt-get install -y \ + vim \ + supervisor + +RUN pip install pip==24.0 + +WORKDIR /app + +ADD requirements.txt ./ +RUN pip install -r ./requirements.txt + +COPY manage.py wsgi.py ./ + +COPY ./appa/ ./appa/ + +RUN python manage.py collectstatic --noinput +RUN chmod -R 777 /app/django_static + +ADD ./client/build /client + +ADD ./supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +ADD ./entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +CMD ["/entrypoint.sh"] diff --git a/appa/__init__.py b/appa/__init__.py new file mode 100644 index 0000000..ae5cef4 --- /dev/null +++ b/appa/__init__.py @@ -0,0 +1 @@ +default_app_config = 'appa.apps.AppaConfig' diff --git a/appa/admin.py b/appa/admin.py new file mode 100644 index 0000000..64c2898 --- /dev/null +++ b/appa/admin.py @@ -0,0 +1,115 @@ + +from django import forms +from django.contrib import admin +from django.core.checks import messages +from django.utils import timezone +from django.utils.safestring import mark_safe +from django.conf import settings +from ace_editor import AceJSONWidget +from rangefilter.filters import DateRangeFilterBuilder + +from .models import * +from .admin_forms import * +from .admin_filters import * +from .tasks import send_call_request_task + + +@admin.register(CallRequest) +class CallRequestAdmin(admin.ModelAdmin): + list_display = ( + 'date', + 'get_status', + 'get_request_status', + 'patient_name', + 'patient_phone', + 'is_active', + ) + list_filter = ( + DateListFilter, + 'status', + 'request_status', + ('date', DateRangeFilterBuilder()), + ) + search_fields = ( + 'patient_name', + 'patient_phone', + ) + form = forms.modelform_factory(CallRequest, fields='__all__', widgets={ + 'data': AceJSONWidget() + }) + actions = ['send_call_request'] + list_editable = ('is_active',) + readonly_fields = ( + 'call_id', + 'call_data', + 'request_time', + 'response_status_code', + 'response_message' + ) + + @admin.display(description='Статус приема', ordering='status') + def get_status(self, obj): + return mark_safe(f''' + {obj.get_status_display()} + ''') + + @admin.display(description='Статус запроса', ordering='request_status') + def get_request_status(self, obj): + return mark_safe(f''' + {obj.get_request_status_display()} + ''') + + @admin.action(description='Отправить выбранные записи на обзвон') + def send_call_request(self, request, queryset): + now = timezone.now() + if settings.CALL_REQUEST_TIME_START <= now.time() <= settings.CALL_REQUEST_TIME_END: + ids = [item.id for item in queryset] + send_call_request_task.apply_async(args=(ids,)) + self.message_user(request, message=f'Записей отправлено на обзвон: {len(ids)}') + else: + self.message_user( + request, + message=f'Время для обзвона регламентируется с ' + f'{settings.CALL_REQUEST_TIME_START} по {settings.CALL_REQUEST_TIME_END}', + level=messages.ERROR + ) + + +@admin.register(RequestLog) +class RequestLogAdmin(admin.ModelAdmin): + list_display = ('request_url', 'request_method', 'response_http_code', 'created') + search_fields = ('request_url', 'request_body', 'response_body', 'response_http_code') + date_hierarchy = 'created' + form = forms.modelform_factory(RequestLog, fields='__all__', widgets={ + 'request_body': AceJSONWidget(read_only=True), + 'response_body': AceJSONWidget(read_only=True) + }) + readonly_fields = [ + 'request_url', + 'request_method', + 'response_http_code', + 'created' + ] + fieldsets = [ + ('', {'fields': ( + 'request_url', + 'request_method', + 'response_http_code', + 'created', + 'request_body', + 'response_body' + )}) + ] + + def has_add_permission(self, request): + return False diff --git a/appa/admin_filters.py b/appa/admin_filters.py new file mode 100644 index 0000000..e195ff4 --- /dev/null +++ b/appa/admin_filters.py @@ -0,0 +1,37 @@ + +import datetime + +from django.conf import settings +from django.contrib import admin +from django.utils import timezone +from django.utils.dateformat import format as django_dateformat +from dateutil import rrule + +__all__ = [ + 'DateListFilter' +] + + +class DateListFilter(admin.SimpleListFilter): + title = 'Дата' + parameter_name = 'date' + + def lookups(self, request, model_admin): + today = datetime.datetime.today() + date_start = today - datetime.timedelta(days=3) + date_end = today + datetime.timedelta(days=2) + + lookups = [] + for date in rrule.rrule(rrule.DAILY, dtstart=date_start, until=date_end): + postfix = '' + if date.date() == today.date(): + postfix = ' (сегодня)' + lookups.append([ + date.strftime('%Y-%m-%d'), f"{django_dateformat(date, 'D d E')}{postfix}" + ]) + + return lookups + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(date=self.value()) diff --git a/appa/admin_forms.py b/appa/admin_forms.py new file mode 100644 index 0000000..fbe5134 --- /dev/null +++ b/appa/admin_forms.py @@ -0,0 +1,7 @@ + +from django import forms + +from .models import * + +__all__ = [ +] diff --git a/appa/api.py b/appa/api.py new file mode 100644 index 0000000..b6df5ef --- /dev/null +++ b/appa/api.py @@ -0,0 +1,45 @@ + +import requests +import datetime + +from constance import config +from typing import List +from requests.exceptions import ConnectTimeout, ConnectionError +from ninja import NinjaAPI +from django.http import Http404, HttpResponse +from django.db.models import ObjectDoesNotExist +from django.template import Context, Template + +from appa.models import * +from appa.schemas import * + +api = NinjaAPI() + + +class ServiceUnavailableError(Exception): + pass + + +@api.exception_handler(ServiceUnavailableError) +@api.exception_handler(ConnectTimeout) +@api.exception_handler(ConnectionError) +def service_unavailable(request, exc): + return api.create_response( + request, + { + "error": "Сервис МТС недоступен" + }, + status=200 + ) + + +@api.exception_handler(Http404) +@api.exception_handler(ObjectDoesNotExist) +def not_found(request, exc): + return api.create_response( + request, + { + "error": "Запись не найдена" + }, + status=404 + ) diff --git a/appa/apps.py b/appa/apps.py new file mode 100644 index 0000000..789059c --- /dev/null +++ b/appa/apps.py @@ -0,0 +1,11 @@ + +from django.apps import AppConfig as DjangoAppConfig + + +class AppaConfig(DjangoAppConfig): + + name = 'appa' + verbose_name = 'МТС' + + def ready(self): + import appa.receivers diff --git a/appa/celery.py b/appa/celery.py new file mode 100644 index 0000000..ad5b9c5 --- /dev/null +++ b/appa/celery.py @@ -0,0 +1,11 @@ + +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'appa.settings') + +app = Celery('appa') +app.config_from_object('django.conf:settings') + +app.autodiscover_tasks() diff --git a/appa/management/commands/async_task.py b/appa/management/commands/async_task.py new file mode 100644 index 0000000..02bf8f7 --- /dev/null +++ b/appa/management/commands/async_task.py @@ -0,0 +1,38 @@ + +import importlib +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument( + 'task', + nargs='?', + ) + parser.add_argument( + 'params', + nargs='*', + ) + parser.add_argument( + '--async', + action='store_true', + dest='async', + ) + + def handle(self, *args, **options): + task_path = options['task'] + p, m = task_path.rsplit('.', 1) + try: + mod = importlib.import_module(p) + except ModuleNotFoundError as e: + raise CommandError(e) + + if not hasattr(mod, m): + raise CommandError(f'Task {m} not found') + + task = getattr(mod, m) + if options['async']: + task.apply_async(*options['params']) + else: + task(*options['params']) diff --git a/appa/management/commands/clean_permissions.py b/appa/management/commands/clean_permissions.py new file mode 100644 index 0000000..f8c301f --- /dev/null +++ b/appa/management/commands/clean_permissions.py @@ -0,0 +1,17 @@ + +from django.db.models import Q +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType + + +class Command(BaseCommand): + + def handle(self, *args, **options): + content_types = ContentType.objects.filter( + Q(app_label='admin', model='logentry') | + Q(app_label='auth', model='permission') | + Q(app_label='sessions', model='session') | + Q(app_label='contenttypes', model='contenttype') + ) + Permission.objects.filter(content_type__in=content_types).delete() diff --git a/appa/management/commands/post_install.py b/appa/management/commands/post_install.py new file mode 100644 index 0000000..7503328 --- /dev/null +++ b/appa/management/commands/post_install.py @@ -0,0 +1,19 @@ + +import os + +from django.core.management.base import BaseCommand +from django.core import management +from django.contrib.auth import get_user_model +from django.db import connection + +SUPERUSER_USERNAME = 'admin' + + +class Command(BaseCommand): + + def handle(self, *args, **options): + management.call_command('migrate', verbosity=1) + + if not get_user_model().objects.filter(username=SUPERUSER_USERNAME).exists(): + os.environ['DJANGO_SUPERUSER_PASSWORD'] = 'admin' + management.call_command('createsuperuser', '--username', SUPERUSER_USERNAME, '--email', 'admin@admin.local', '--noinput', verbosity=0) diff --git a/appa/management/commands/set_tomorrow.py b/appa/management/commands/set_tomorrow.py new file mode 100644 index 0000000..3976cb6 --- /dev/null +++ b/appa/management/commands/set_tomorrow.py @@ -0,0 +1,12 @@ + +import datetime + +from django.core.management.base import BaseCommand +from appa.models import CallRequest + + +class Command(BaseCommand): + + def handle(self, *args, **options): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + CallRequest.objects.all().update(date=tomorrow) diff --git a/appa/managers.py b/appa/managers.py new file mode 100644 index 0000000..99e46a1 --- /dev/null +++ b/appa/managers.py @@ -0,0 +1,10 @@ + +from django.db import models + + +class CallRequestQuerySet(models.QuerySet): + pass + + +class CallRequestManager(models.Manager): + pass diff --git a/appa/migrations/0001_initial.py b/appa/migrations/0001_initial.py new file mode 100644 index 0000000..044fa6f --- /dev/null +++ b/appa/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2 on 2024-09-05 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='RequestLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('request_url', models.CharField(max_length=500, verbose_name='Адрес запроса')), + ('request_body', models.TextField(blank=True, null=True, verbose_name='Запрос')), + ('request_method', models.CharField(max_length=100, null=True, verbose_name='Тип запроса')), + ('response_body', models.TextField(blank=True, null=True, verbose_name='Ответ')), + ('response_http_code', models.CharField(max_length=4, null=True, verbose_name='HTTP статус ответа')), + ('created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата запроса')), + ], + options={ + 'verbose_name': 'Запрос', + 'verbose_name_plural': 'История запросов', + 'ordering': ('-created',), + }, + ), + migrations.CreateModel( + name='CallRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name='Дата')), + ('patient_id', models.IntegerField(verbose_name='ID пациента')), + ('patient_name', models.CharField(max_length=200, verbose_name='ФИО пациента')), + ('patient_phone', models.CharField(max_length=100, verbose_name='Номер телефона')), + ('status', models.CharField(choices=[('PENDING', 'Обзвон еще не состоялся'), ('APPROVED', 'Прием подтвержден'), ('CANCELED', 'Прием отменен'), ('WITHOUT_ANSWER', 'Не дозвонились'), ('SERVICE_UNAVAILABLE', 'Сервис недоступен')], default='PENDING', max_length=20, verbose_name='Статус')), + ('data', models.JSONField(default=dict, verbose_name='Данные')), + ], + options={ + 'verbose_name': 'Запрос на звонок', + 'verbose_name_plural': 'Запросы на звонки', + 'unique_together': {('date', 'patient_id')}, + }, + ), + ] diff --git a/appa/migrations/0002_auto_20240908_1121.py b/appa/migrations/0002_auto_20240908_1121.py new file mode 100644 index 0000000..ac06dd5 --- /dev/null +++ b/appa/migrations/0002_auto_20240908_1121.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-09-08 11:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appa', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='callrequest', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Активность'), + ), + migrations.AlterField( + model_name='callrequest', + name='data', + field=models.JSONField(blank=True, default=list, verbose_name='Данные'), + ), + ] diff --git a/appa/migrations/0003_auto_20241103_1105.py b/appa/migrations/0003_auto_20241103_1105.py new file mode 100644 index 0000000..fb55389 --- /dev/null +++ b/appa/migrations/0003_auto_20241103_1105.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2 on 2024-11-03 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appa', '0002_auto_20240908_1121'), + ] + + operations = [ + migrations.AddField( + model_name='callrequest', + name='address', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Адрес'), + ), + migrations.AddField( + model_name='callrequest', + name='call_data', + field=models.JSONField(blank=True, default=dict, editable=False), + ), + migrations.AddField( + model_name='callrequest', + name='call_id', + field=models.CharField(editable=False, max_length=100, null=True, verbose_name='ID вызова'), + ), + migrations.AddField( + model_name='callrequest', + name='doctor_name', + field=models.CharField(max_length=100, null=True, verbose_name='Врач'), + ), + migrations.AddField( + model_name='callrequest', + name='request_time', + field=models.DateTimeField(editable=False, null=True), + ), + migrations.AddField( + model_name='callrequest', + name='response_message', + field=models.TextField(editable=False, null=True), + ), + migrations.AddField( + model_name='callrequest', + name='response_status_code', + field=models.IntegerField(editable=False, null=True), + ), + migrations.AddField( + model_name='callrequest', + name='service_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Услуга'), + ), + ] diff --git a/appa/migrations/0004_auto_20241103_1122.py b/appa/migrations/0004_auto_20241103_1122.py new file mode 100644 index 0000000..ff633fa --- /dev/null +++ b/appa/migrations/0004_auto_20241103_1122.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2 on 2024-11-03 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appa', '0003_auto_20241103_1105'), + ] + + operations = [ + migrations.AddField( + model_name='callrequest', + name='request_status', + field=models.CharField(choices=[('PENDING', 'Запрос не отправлен'), ('APPROVED', 'Запрос отправлен'), ('ERROR', 'Ошибка'), ('SERVICE_UNAVAILABLE', 'Сервис недоступен')], default='PENDING', max_length=20, verbose_name='Статус запроса'), + ), + migrations.AlterField( + model_name='callrequest', + name='call_id', + field=models.CharField(editable=False, max_length=100, null=True, verbose_name='ID'), + ), + migrations.AlterField( + model_name='callrequest', + name='status', + field=models.CharField(choices=[('PENDING', 'Обзвон еще не состоялся'), ('APPROVED', 'Прием подтвержден'), ('CANCELED', 'Прием отменен'), ('WITHOUT_ANSWER', 'Не дозвонились')], default='PENDING', max_length=20, verbose_name='Статус приема'), + ), + ] diff --git a/appa/migrations/__init__.py b/appa/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/appa/models.py b/appa/models.py new file mode 100644 index 0000000..0d1014d --- /dev/null +++ b/appa/models.py @@ -0,0 +1,114 @@ + +import json +from django.db import models + +from .managers import CallRequestManager + +__all__ = [ + 'CallRequest', + 'RequestLog' +] + + +class CallRequest(models.Model): + + class Status(models.TextChoices): + PENDING = 'PENDING', 'Обзвон еще не состоялся' + APPROVED = 'APPROVED', 'Прием подтвержден' + CANCELED = 'CANCELED', 'Прием отменен' + WITHOUT_ANSWER = 'WITHOUT_ANSWER', 'Не дозвонились' + + STATUS_COLOR = { + Status.PENDING: '#2D72D2', + Status.APPROVED: '#238551', + Status.CANCELED: '#CD4246', + Status.WITHOUT_ANSWER: '#866103', + } + + class RequestStatus(models.TextChoices): + NOT_SENT = 'PENDING', 'Не отправлен' + SENT = 'APPROVED', 'Отправлен' + ERROR = 'ERROR', 'Ошибка' + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', 'Сервис недоступен' + + REQUEST_STATUS_COLOR = { + RequestStatus.NOT_SENT: '#2D72D2', + RequestStatus.SENT: '#238551', + RequestStatus.ERROR: '#CD4246', + RequestStatus.SERVICE_UNAVAILABLE: '#7961DB', + } + + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING, + verbose_name='Статус приема') + request_status = models.CharField(max_length=20, choices=RequestStatus.choices, default=RequestStatus.NOT_SENT, + verbose_name='Статус запроса') + date = models.DateField(verbose_name='Дата') + patient_id = models.IntegerField(verbose_name='ID пациента') + patient_name = models.CharField(max_length=200, verbose_name='ФИО пациента') + patient_phone = models.CharField(max_length=100, verbose_name='Номер телефона') + doctor_name = models.CharField(max_length=100, null=True, verbose_name='Врач') + service_name = models.CharField(max_length=100, null=True, blank=True, verbose_name='Услуга') + address = models.CharField(max_length=100, null=True, blank=True, verbose_name='Адрес') + data = models.JSONField(default=list, blank=True, verbose_name='Данные') + is_active = models.BooleanField(default=True, verbose_name='Активность') + + call_id = models.CharField(max_length=100, null=True, editable=False, verbose_name='ID') + call_data = models.JSONField(default=dict, blank=True, editable=False) + request_time = models.DateTimeField(null=True, editable=False) + response_status_code = models.IntegerField(null=True, editable=False) + response_message = models.TextField(null=True, editable=False) + + class Meta: + verbose_name = 'Запрос на звонок' + verbose_name_plural = 'Запросы на звонки' + unique_together = ( + ('date', 'patient_id'), + ) + + objects = CallRequestManager() + + def __str__(self): + return f"{self.date.strftime('%d.%m.%Y')} / {self.patient_name}" + + def get_status_color(self): + return self.STATUS_COLOR.get(self.status) + + def get_request_status_color(self): + return self.REQUEST_STATUS_COLOR.get(self.request_status) + + def reset_request(self): + self.request_status = CallRequest.RequestStatus.NOT_SENT + self.request_time = None + self.response_status_code = None + self.response_message = None + self.save(update_fields=( + 'request_status', + 'request_time', + 'response_status_code', + 'response_message', + )) + + +class RequestLog(models.Model): + + class Meta: + verbose_name = 'Запрос' + verbose_name_plural = 'История запросов' + ordering = ('-created',) + + request_url = models.CharField(max_length=500, verbose_name='Адрес запроса') + request_body = models.TextField(null=True, blank=True, verbose_name='Запрос') + request_method = models.CharField(max_length=100, null=True, verbose_name='Тип запроса') + + response_body = models.TextField(null=True, blank=True, verbose_name='Ответ') + response_http_code = models.CharField(max_length=4, null=True, verbose_name='HTTP статус ответа') + + created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name='Дата запроса') + + def __str__(self): + return self.request_url + + def response_hook(self, r, *args, **kwargs): + self.response_http_code = r.status_code + self.response_body = r.text + self.save() diff --git a/appa/mts_api.py b/appa/mts_api.py new file mode 100644 index 0000000..f43f684 --- /dev/null +++ b/appa/mts_api.py @@ -0,0 +1,58 @@ + +import datetime +import json + +import requests +from django.utils import timezone +from constance import config + +from .models import CallRequest, RequestLog + + +def add_call_request(call_request: CallRequest, logging=True): + call_request.reset_request() + + data = dict( + number_b=call_request.patient_phone, + name=call_request.patient_name, + date=call_request.date.strftime('%Y-%m-%d'), + time=call_request.date.strftime('%H:%M'), + doctor=call_request.doctor_name, + address=call_request.address, + ) + + hooks = None + if logging: + log = RequestLog( + request_url=config.HOST, + request_body=json.dumps(data, ensure_ascii=False, indent=4), + ) + hooks = {'response': log.response_hook} + + r = requests.post( + config.HOST, + auth=(config.USERNAME, config.PASSWORD), + json=data, + hooks=hooks + ) + call_request.response_status_code = r.status_code + call_request.request_time = timezone.now() + if r.status_code == 200: + call_request.call_id = r.json()['request_id'] + call_request.request_status = CallRequest.RequestStatus.SENT + elif r.status_code == 400: + call_request.call_id = r.json()['request_id'] + call_request.response_message = r.json()['error'] + call_request.request_status = CallRequest.RequestStatus.ERROR + + call_request.save(update_fields=( + 'call_id', + 'request_status', + 'request_time', + 'response_status_code', + 'response_message', + )) + + +def call_history(date_from: datetime.date, date_to: datetime.date): + pass diff --git a/appa/receivers.py b/appa/receivers.py new file mode 100644 index 0000000..45ac448 --- /dev/null +++ b/appa/receivers.py @@ -0,0 +1,5 @@ + +from django.dispatch import receiver +from django.db.models.signals import pre_delete + +from .models import * diff --git a/appa/schemas.py b/appa/schemas.py new file mode 100644 index 0000000..7eca75f --- /dev/null +++ b/appa/schemas.py @@ -0,0 +1,4 @@ + +from typing import List, Optional +from ninja import Schema, ModelSchema, Field +from appa.models import * diff --git a/appa/settings.py b/appa/settings.py new file mode 100644 index 0000000..b04a5e2 --- /dev/null +++ b/appa/settings.py @@ -0,0 +1,194 @@ + +import os +import environ +import datetime +import django as django_module + +from celery.schedules import crontab +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +DJANGO_DIR = os.path.dirname(django_module.__file__) + +SECRET_KEY = 'django-insecure-13n6^zl86r@=9@!z8gsfv(q6wpggo+$box)^tklnoe9auck-)c' + +root = environ.Path(__file__) - 2 + +env = environ.Env( + DEBUG=(bool, False), + LANGUAGE_CODE=(str, 'ru-RU'), + TIME_ZONE=(str, 'Asia/Vladivostok'), + USE_TZ=(bool, False), + USE_I18N=(bool, True), + CORS_ORIGIN_WHITELIST=(list, []), + AUTH_CONVERT_PASSWORD_TO_LOWER_CASE=(bool, False) +) + +DEBUG = env('DEBUG', False) + +REDIS_HOST = env.str('REDIS_HOST', 'medicine-stack-redis') +REDIS_PORT = env.str('REDIS_PORT', '6379') +REDIS_DB = env.str('REDIS_DB', 1) + +POSTGRES_HOST = env.str('POSTGRES_HOST', 'medicine-stack-postgres') +POSTGRES_PORT = env.str('POSTGRES_PORT', '5432') +POSTGRES_DB = env.str('POSTGRES_DB', 'medicine_mts') +POSTGRES_USER = env.str('POSTGRES_USER', 'postgres') +POSTGRES_PASSWORD = env.str('POSTGRES_PASSWORD', 'password') + +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'constance', + 'ace_editor', + 'rangefilter', + + 'appa', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +ROOT_URLCONF = 'appa.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'appa/templates'), + DJANGO_DIR + ], + '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 = 'appa.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': POSTGRES_HOST, + 'PORT': POSTGRES_PORT, + 'NAME': POSTGRES_DB, + 'USER': POSTGRES_USER, + 'PASSWORD': POSTGRES_PASSWORD, + }, + 'medicine': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'medicine', + 'USER': POSTGRES_USER, + 'PASSWORD': POSTGRES_PASSWORD, + 'HOST': POSTGRES_HOST, + 'PORT': POSTGRES_PORT + }, +} + +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', + }, +] + +LANGUAGE_CODE = env.str('LANGUAGE_CODE') + +TIME_ZONE = env.str('TIME_ZONE') + +USE_I18N = env.bool('USE_I18N') + +USE_TZ = env.bool('USE_TZ') + +STATIC_URL = '/medicine-mts/django_static/' + +STATIC_ROOT = os.path.join(BASE_DIR, 'django_static') + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +SESSION_COOKIE_NAME = env.str('SESSION_COOKIE_NAME', 'medicine-mts') + +CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' + +CONSTANCE_ADDITIONAL_FIELDS = { + 'char': ['django.forms.fields.CharField', { + 'widget': 'django.forms.TextInput', + 'widget_kwargs': dict(attrs={'size': 60}), + 'required': False + }], + 'url': ['django.forms.fields.URLField', { + 'widget': 'django.forms.TextInput', + 'widget_kwargs': dict(attrs={'size': 60}), + 'required': False + }], +} + +CONSTANCE_CONFIG = { + 'USERNAME': ('', 'Имя пользователя', 'char'), + 'PASSWORD': ('', 'Пароль', 'char'), + 'HOST': ('', 'Хост', 'char'), + 'IS_ENABLED': (False, 'Активен', bool), + + 'MEDICINE_TOKEN': ('', 'Токен', 'char'), + 'MEDICINE_HOST': ('http://medicine-app/', 'Хост', 'char'), +} + +CONSTANCE_CONFIG_FIELDSETS = ( + ('МТС', ( + 'HOST', + 'USERNAME', + 'PASSWORD', + 'IS_ENABLED', + )), + ('МИС', ( + 'MEDICINE_HOST', + 'MEDICINE_TOKEN', + )), +) + +REQUEST_TIMEOUT = 5 + +REDIS_CELERY_DB = env.str('REDIS_CELERY_DB', '3') +BROKER_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_CELERY_DB}' +CELERY_DEFAULT_QUEUE = 'default' +CELERY_ENABLE_UTC = True +CELERY_IGNORE_RESULT = True + +CELERYBEAT_SCHEDULE = { +} + +DATE_FORMAT = 'd.m.Y' +DATETIME_FORMAT = 'd.m.Y H:i' + +CALL_REQUEST_TIME_START = datetime.time(9, 0) +CALL_REQUEST_TIME_END = datetime.time(21, 0) + +if DEBUG: + AUTH_PASSWORD_VALIDATORS = [] + SESSION_COOKIE_NAME = 'dev-medicine-mts' diff --git a/appa/tasks.py b/appa/tasks.py new file mode 100644 index 0000000..e7c6e5f --- /dev/null +++ b/appa/tasks.py @@ -0,0 +1,72 @@ +import datetime +import requests +from constance import config + +from appa.celery import app + +from appa.models import * +from appa.mts_api import add_call_request + + +@app.task(bind=True, acks_late=True) +def send_call_request_task(self, ids=None): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + + call_requests = CallRequest.objects.filter( + date=tomorrow, + status__in=[ + CallRequest.Status.PENDING, + CallRequest.Status.WITHOUT_ANSWER + ], + is_active=True + ) + if ids and len(ids) > 0: + call_requests = call_requests.filter(id__in=ids) + + for call_request in call_requests: + add_call_request(call_request) + + +@app.task(bind=True, acks_late=True) +def update_call_requests(self): + for add_days in [1, 2]: + booking_date = datetime.date.today() + datetime.timedelta(days=add_days) + CallRequest.objects.filter(date=booking_date).delete() + + for page in range(1): + r = requests.get( + f'{config.MEDICINE_HOST}api/1/booking/', + params={ + 'date_start__date': booking_date.strftime('%d.%m.%Y'), + 'patient__phone_mobile__isnull': False, + 'patient__isnull': False, + 'page': page, + 'status': 1, + 'limit': 50 + }, + headers={ + 'Authorization': f'Token {config.MEDICINE_TOKEN}' + } + ) + if r.status_code == 200: + response = r.json() + results = response['results'] + for booking in results: + call_request = CallRequest.objects.get_or_create( + defaults={ + 'patient_name': booking['patient']['full_name'], + 'patient_phone': '7' + booking['patient']['phone_mobile'], + }, + date=booking_date, + patient_id=booking['patient']['id'], + )[0] + call_request.data.append({ + 'booking_id': booking['id'], + 'time_start': booking['time_start'], + 'time_end': booking['time_end'], + 'speciality': booking['user']['doctor']['speciality_display'], + }) + call_request.save(update_fields=['data']) + + if response['next'] is None: + break diff --git a/appa/templates/admin/appa/callrequest/change_list.html b/appa/templates/admin/appa/callrequest/change_list.html new file mode 100644 index 0000000..5846791 --- /dev/null +++ b/appa/templates/admin/appa/callrequest/change_list.html @@ -0,0 +1,11 @@ +{% extends "admin/change_list.html" %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} \ No newline at end of file diff --git a/appa/templates/admin/appa/callrequest/change_list_results.html b/appa/templates/admin/appa/callrequest/change_list_results.html new file mode 100644 index 0000000..0b313e8 --- /dev/null +++ b/appa/templates/admin/appa/callrequest/change_list_results.html @@ -0,0 +1,38 @@ +{% load i18n static %} +{% if result_hidden_fields %} +
{# DIV for HTML validation #} +{% for item in result_hidden_fields %}{{ item }}{% endfor %} +
+{% endif %} +{% if results %} +
+ + + +{% for header in result_headers %} +{% endfor %} + + + +{% for result in results %} +{% if result.form and result.form.non_field_errors %} + +{% endif %} +{% for item in result %}{{ item }}{% endfor %} +{% endfor %} + +
+ {% if header.sortable %} + {% if header.sort_priority > 0 %} +
+ + {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} + +
+ {% endif %} + {% endif %} +
{% if header.sortable %}{{ header.text|capfirst }}{% else %}{{ header.text|capfirst }}{% endif %}
+
+
{{ result.form.non_field_errors }}
+
+{% endif %} diff --git a/appa/templates/admin/base_site.html b/appa/templates/admin/base_site.html new file mode 100644 index 0000000..fa0c41e --- /dev/null +++ b/appa/templates/admin/base_site.html @@ -0,0 +1,10 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block title %}{{ title }} | МТС{% endblock %} + +{% block branding %} +

+ МТС +

+{% endblock %} diff --git a/appa/templates/admin/constance/change_list.html b/appa/templates/admin/constance/change_list.html new file mode 100644 index 0000000..24e1515 --- /dev/null +++ b/appa/templates/admin/constance/change_list.html @@ -0,0 +1,19 @@ +{% extends "admin/constance/change_list.html" %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/appa/test_views.py b/appa/test_views.py new file mode 100644 index 0000000..8fe7a11 --- /dev/null +++ b/appa/test_views.py @@ -0,0 +1,67 @@ + +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +from django.utils.crypto import get_random_string + + +@csrf_exempt +def add_success_view(request): + return JsonResponse({ + 'request_id': get_random_string(10), + 'success': '1' + }) + + +@csrf_exempt +def add_error_view(request): + return JsonResponse({ + 'request_id': get_random_string(10), + 'success': '0', + 'error': "'number_b' should be in e164 format" + }, status=400) + + +@csrf_exempt +def add_500_error_view(request): + return JsonResponse({ + 'status': 500, + 'status_message': 'Internal Server Error' + }, status=500) + + +@csrf_exempt +def history_view(request): + return JsonResponse({ + { + "page": 1, + "total": 51046, + "offset": 0, + "limit": 50, + "prev": "", + "next": "https://***/history?date_from=2023-10-12+09%3A00%3A00&date_to=2023-10-12+18%3A05%3A00&limit=50&offset=50", + "talks": [ + { + "call_id": "000364fa82e81b0e", + "duration": "43", + "number_a": "74993331122", + "number_b": "79155552211", + "s_date": "2023-10-1209:00:25", + "side": "B", + "link_recording": "https://***/000123fa82e81b0e?date_from=2023-10-12T08:59:25Z&date_to=2023-10-12T09:02:25Z", + "status": "отменитьзаказ", + "transcript": "[Робот,hello_main]текстробота\n[Абонент,Hello]текстабонента\n..." + }, + { + "call_id": "002114fa55461eb6", + "duration": "32", + "number_a": "74995443052", + "phone": "79206584411", + "s_date": "2023-10-2219:59:06", + "side": "A", + "link_recording": "https://***/000564fa82e81b0e?date_from=2023-10-12T19:58:06Z&date_to=2023-10-12T20:02:06Z", + "status": "оформитьзаказ", + "transcript": "[Робот,hello_main]текстробота\n[Абонент,Hello]текстабонента\n..." + } + ] + } + }) diff --git a/appa/urls.py b/appa/urls.py new file mode 100644 index 0000000..dd11b03 --- /dev/null +++ b/appa/urls.py @@ -0,0 +1,28 @@ + +from django.contrib import admin +from django.contrib.auth.models import Group +from django.urls import path, include +from django.conf import settings +from django.shortcuts import redirect +from appa.api import api +from appa import test_views + +admin.site.unregister(Group) + +urlpatterns = [ + path('medicine-mts/admin/', admin.site.urls), + path('medicine-mts/api/', api.urls), + path('medicine-mts/', lambda request: redirect('/medicine-mts/admin/')), + path('admin/', lambda request: redirect('/medicine-mts/admin/')), + path('', lambda request: redirect('/medicine-mts/admin/')), +] + +if settings.DEBUG: + from django.contrib.staticfiles.urls import staticfiles_urlpatterns, static + urlpatterns +=[ + path('medicine-mts/tests/add_success_view/', test_views.add_success_view), + path('medicine-mts/tests/add_error_view/', test_views.add_error_view), + path('medicine-mts/tests/add_500_error_view/', test_views.add_500_error_view), + path('medicine-mts/tests/history_view/', test_views.history_view), + ] + urlpatterns += staticfiles_urlpatterns() diff --git a/appa/utils.py b/appa/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/appa/wsgi.py b/appa/wsgi.py new file mode 100644 index 0000000..325e7ae --- /dev/null +++ b/appa/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'appa.settings') + +application = get_wsgi_application() diff --git a/asgi.py b/asgi.py new file mode 100644 index 0000000..f489a47 --- /dev/null +++ b/asgi.py @@ -0,0 +1,8 @@ + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'appa.settings') + +application = get_asgi_application() diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6bfa730 --- /dev/null +++ b/build.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e + +(cd client && npm run build) +docker build --tag docker.med-logic.ru/medicine-damask:latest . +docker push docker.med-logic.ru/medicine-damask:latest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd622f9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ + +version: '3.4' + +services: + + local-server: + restart: "no" + container_name: "medicine-mts" + tty: True + image: python-local-3.12 + environment: + DEBUG: 'True' + + POSTGRES_HOST: docker.for.mac.localhost + POSTGRES_PORT: 54322 + POSTGRES_USER: user + POSTGRES_PASSWORD: password + + REDIS_HOST: docker.for.mac.localhost + REDIS_PORT: 63790 + REDIS_DB: 1 + volumes: + - .:/app + - ./volumes/venv:/venv + networks: + - shared-network + ports: + - "9800:80" + + prod: + restart: "no" + container_name: "medicine-mts-prod" + tty: True + image: docker.med-logic.ru/medicine-mts:latest + environment: + DEBUG: 'False' + + POSTGRES_HOST: docker.for.mac.localhost + POSTGRES_PORT: 54322 + POSTGRES_USER: user + POSTGRES_PASSWORD: password + + REDIS_HOST: docker.for.mac.localhost + REDIS_PORT: 63790 + REDIS_DB: 1 + networks: + - shared-network + ports: + - "9900:80" + +networks: + shared-network: + external: true diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..ff24433 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +python manage.py migrate + +/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/l b/l new file mode 100755 index 0000000..ce46778 --- /dev/null +++ b/l @@ -0,0 +1,102 @@ +#!/bin/bash + +COMMAND="$1" +ARGUMENT="$2" + +up() { + docker-compose up -d local-server +} + +stop() { + docker-compose stop local-server +} + +enter() { + up + docker-compose exec local-server bash +} + +python_env() { + export PYTHONPATH="$PYTHONPATH:/app/packages" + source /venv/bin/activate +} + + +s() { + python_env + echo "" + echo "http://0.0.0.0:9800" + echo "" + uwsgi --http 0.0.0.0:80 \ + --wsgi-file wsgi.py \ + --processes 2 \ + --py-autoreload 1 \ + --http-timeout 360 \ + --buffer-size 32768 \ + --need-app \ + --disable-logging + -- +} + +makemigrations() { + python_env + python manage.py makemigrations +} + +empty_migration() { + python_env + python manage.py makemigrations ${ARGUMENT} --empty +} + +manage() { + python_env + python manage.py ${ARGUMENT} +} + +migrate() { + python_env + python manage.py migrate ${ARGUMENT} +} + +mm() { + python_env + python manage.py makemigrations + python manage.py migrate +} + +runcelery() { + python_env + celery -A appa worker -B --loglevel=INFO +} + +dumpdata() { + python_env + python manage.py dumpdata ${ARGUMENT} --indent=4 > tms/fixtures/${ARGUMENT}.json +} + +run() { + up + enter +} + +pipi() { + python_env + pip install ${ARGUMENT} + pip freeze +} + +run_react() { + docker-compose up -d local-react + docker-compose exec local-react bash +} + +build_image() { + docker build --tag docker.med-logic.ru/cmt:latest . + docker push docker.med-logic.ru/cmt:latest +} + +damask() { + ssh -4 -g -N -o ProxyCommand="ssh -W %h:%p root@46.229.212.181" -L 50000:192.168.11.101:80 rick@localhost -p 40050 +} + +${COMMAND} diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..845f0bc --- /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', 'appa.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..215c6a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,36 @@ +ace-editor @ git+https://github.com/ilya-muhortov/django-ace-editor@v0.1 +amqp==5.2.0 +annotated-types==0.7.0 +asgiref==3.8.1 +billiard==4.2.0 +celery==5.4.0 +certifi==2024.8.30 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +Django==3.2 +django-constance==3.1.0 +django-environ==0.11.2 +django-ninja==1.2.0 +django-picklefield==3.2 +idna==3.8 +kombu==5.4.0 +prompt_toolkit==3.0.47 +psycopg2-binary==2.9.9 +pydantic==2.8.2 +pydantic_core==2.20.1 +python-dateutil==2.9.0.post0 +pytz==2024.1 +redis==5.0.7 +requests==2.32.3 +setuptools==70.2.0 +six==1.16.0 +sqlparse==0.5.1 +typing_extensions==4.12.2 +tzdata==2024.1 +urllib3==2.2.2 +uWSGI==2.0.26 +vine==5.1.0 +wcwidth==0.2.13 \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..ed310aa --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,43 @@ +[supervisord] +nodaemon=true + +[program:app] +command=uwsgi + --http 0.0.0.0:80 + --http-timeout 15 + --buffer-size 32768 + --processes 2 + --threads 2 + --chdir /app/ + --wsgi-file wsgi.py + --harakiri 30 + --evil-reload-on-as 500 + --master + --log-5xx + --vacuum + --need-app + --disable-write-exception + --static-map /medicine-mts/django_static=/app/django_static +autostart=true +autorestart=true +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes = 0 +stderr_maxbytes=0 + +[program:worker] +directory=/app +command=celery -A appa worker -Q default -n worker@appa --soft-time-limit=3600 --concurrency=1 --loglevel=INFO +autostart=true +autorestart=true +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes = 0 +stderr_maxbytes=0 + +[program:scheduler] +directory=/app +command=celery -A appa beat --loglevel=INFO +autostart=true +autorestart=true +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes = 0 +stderr_maxbytes=0 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..a6d64b7 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,8 @@ + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'appa.settings') + +application = get_wsgi_application()