diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..562cd11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.DS_Store +Thumbs.db +*.log +*.sqlite3 +*.pyc +__pycache__/ +.pytest_cache/ +.venv/ +venv/ +env/ +.env +.env.local +.next/ +node_modules/ +dist/ +coverage/ +media/ +staticfiles/ + diff --git a/README.md b/README.md index 235b0fe..163aae9 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# kkpab-site +# KKPAB Site + +Modernized rebuild of `kkpab26.ru` using: + +- Django + Django REST Framework for content management API +- Next.js for the public website +- PostgreSQL for production data +- Nginx for reverse proxy and static/media delivery + +## Structure + +- `backend/` Django API and admin +- `frontend/` Next.js public website +- `deploy/` deployment templates + +## Local setup + +### Backend + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +python manage.py migrate +python manage.py loaddata content/fixtures/initial_data.json +python manage.py createsuperuser +python manage.py runserver 0.0.0.0:7788 +``` + +### Frontend + +```bash +cd frontend +npm install +cp .env.local.example .env.local +npm run dev +``` + +Frontend expects API at `http://localhost:7788/api`. + +## Production notes + +- build frontend with `npm run build` +- run Django with `gunicorn` +- route `/api`, `/admin`, `/media`, `/static` to Django +- route `/` to Next.js diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..960678c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +DJANGO_SECRET_KEY=change-me +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend +DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000 +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/kkpab diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..44ce583 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..c4d6972 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() + diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..1c8cdf5 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,110 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") +DEBUG = os.getenv("DJANGO_DEBUG", "False").lower() == "true" +ALLOWED_HOSTS = [host.strip() for host in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if host.strip()] + +if DEBUG: + for internal_host in ("backend", "backend-1", "localhost", "127.0.0.1"): + if internal_host not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append(internal_host) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "rest_framework", + "content", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +db_url = os.getenv("DATABASE_URL", "sqlite:///db.sqlite3") + +if db_url.startswith("postgresql://"): + from urllib.parse import urlparse + + parsed = urlparse(db_url) + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": parsed.path.lstrip("/"), + "USER": parsed.username, + "PASSWORD": parsed.password, + "HOST": parsed.hostname, + "PORT": parsed.port or 5432, + } + } +else: + DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}} + +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 = "ru-ru" +TIME_ZONE = "Europe/Moscow" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CORS_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv("DJANGO_CORS_ALLOWED_ORIGINS", "").split(",") + if origin.strip() +] + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ] +} diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..5981f98 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,10 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("content.urls")), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..67a3358 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() + diff --git a/backend/content/__init__.py b/backend/content/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/content/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/content/admin.py b/backend/content/admin.py new file mode 100644 index 0000000..81b8166 --- /dev/null +++ b/backend/content/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin + +from .models import ContactMessage, Doctor, Document, Page, Service, SiteSettings + + +@admin.register(SiteSettings) +class SiteSettingsAdmin(admin.ModelAdmin): + list_display = ("site_name", "phone", "city", "updated_at") + + +@admin.register(Page) +class PageAdmin(admin.ModelAdmin): + list_display = ("title", "slug", "is_published", "nav_order", "updated_at") + list_editable = ("is_published", "nav_order") + prepopulated_fields = {"slug": ("title",)} + + +@admin.register(Service) +class ServiceAdmin(admin.ModelAdmin): + list_display = ("title", "slug", "sort_order", "updated_at") + list_editable = ("sort_order",) + prepopulated_fields = {"slug": ("title",)} + + +@admin.register(Doctor) +class DoctorAdmin(admin.ModelAdmin): + list_display = ("full_name", "position", "sort_order", "updated_at") + list_editable = ("sort_order",) + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ("title", "category", "sort_order", "updated_at") + list_editable = ("sort_order",) + + +@admin.register(ContactMessage) +class ContactMessageAdmin(admin.ModelAdmin): + list_display = ("name", "phone", "email", "is_processed", "created_at") + list_editable = ("is_processed",) + readonly_fields = ("name", "phone", "email", "message", "created_at", "updated_at") + diff --git a/backend/content/apps.py b/backend/content/apps.py new file mode 100644 index 0000000..0fbdeba --- /dev/null +++ b/backend/content/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "content" + diff --git a/backend/content/fixtures/initial_data.json b/backend/content/fixtures/initial_data.json new file mode 100644 index 0000000..e2b995b --- /dev/null +++ b/backend/content/fixtures/initial_data.json @@ -0,0 +1,237 @@ +[ + { + "model": "content.sitesettings", + "pk": 1, + "fields": { + "site_name": "Кон�ультационное клиниче�кое патологоанатомиче�кое бюро", + "tagline": "Точна� морфологиче�ка� диагно�тика и профе��иональное �опровождение", + "phone": "+7 (8652) 22-66-77", + "email": "kkpab@bk.ru", + "address": "улица Балакирева, 5к7", + "city": "Ставрополь", + "working_hours": "Пн-Пт 08:00-17:00, Сб 09:00-14:00", + "hero_title": "Современна� патоморфологи� � вниманием к клиниче�кой точно�ти", + "hero_subtitle": "Перео�мы�ленный цифровой �айт дл� бюро, где ключева� информаци�, �пециали�ты, документы и направлени� на и��ледовани� до�тупны бы�тро и пон�тно.", + "hero_image": "https://images.unsplash.com/photo-1579154204601-01588f351e67?auto=format&fit=crop&w=1600&q=80", + "about_excerpt": "Бюро объедин�ет �пециали�тов по патологиче�кой анатомии и ги�тологии, обе�печива� каче�твенную диагно�тику и профе��иональную коммуникацию � медицин�кими организаци�ми." + } + }, + { + "model": "content.page", + "pk": 1, + "fields": { + "title": "О бюро", + "slug": "about", + "intro": "Специали�ты разного уровн� и �овременное оборудование дл� морфологиче�кой диагно�тики.", + "body": "В патологоанатомиче�ком бюро работают врачи � большим �тажем, квалификационными категори�ми и по�то�нным профе��иональным развитием. Дл� ги�тологиче�кой обработки и�пользует�� �овременное вы�окотехнологичное оборудование, а диагно�тиче�кий проце�� поддерживает�� подготовленным �редним и младшим медицин�ким пер�оналом.", + "meta_title": "О бюро", + "meta_description": "Информаци� о кон�ультационном клиниче�ком патологоанатомиче�ком бюро.", + "is_published": true, + "nav_order": 1 + } + }, + { + "model": "content.page", + "pk": 2, + "fields": { + "title": "Сведени� о медицин�кой организации", + "slug": "svedeniya-mo", + "intro": "Юридиче�ка� информаци�, лицензии, график и данные �траховых организаций.", + "body": "Полное наименование: ООО \"Кон�ультационное Клиниче�кое Патологоанатомиче�кое Бюро\". ОГР� 1182651002093. Режим работы: пн-пт � 8:00 до 17:00, �уббота � 9:00 до 14:00. Генеральный директор: Балабеков �ндрей Вагифович. Телефон: +7 (8652) 22-66-77. Электронна� почта: kkpab@bk.ru.", + "meta_title": "Сведени� МО", + "meta_description": "Официальные �ведени� о медицин�кой организации.", + "is_published": true, + "nav_order": 2 + } + }, + { + "model": "content.service", + "pk": 1, + "fields": { + "title": "Ги�тологиче�кие и��ледовани�", + "slug": "histology", + "short_description": "Базовый и наиболее ра�про�траненный метод и��ледовани� тканей.", + "description": "Ги�тологиче�кое и��ледование и�пользует�� дл� диагно�тики опухолевых и неопухолевых заболеваний. Материал проходит фик�ацию, ги�топроце��инг, заливку в парафин, микротомию, окрашивание и оценку врачом-патоморфологом.", + "icon": "microscope", + "sort_order": 1 + } + }, + { + "model": "content.service", + "pk": 2, + "fields": { + "title": "Кон�ультаци� морфологиче�ких препаратов", + "slug": "consultations", + "short_description": "Профе��иональна� верификаци� и �к�пертна� оценка �ложных �лучаев.", + "description": "Кон�ультаци� препаратов помогает уточнить диагноз, подтвердить �ложные клиниче�кие �лучаи и обе�печить единый профе��иональный �тандарт интерпретации.", + "icon": "stethoscope", + "sort_order": 2 + } + }, + { + "model": "content.service", + "pk": 3, + "fields": { + "title": "Документационное �опровождение", + "slug": "documents-support", + "short_description": "Подготовка об�зательной информации и до�туп к официальным документам.", + "description": "Пациенты и партнеры получают бы�трый до�туп к �ведени�м о медицин�кой организации, лицензии, графику работы и контактам.", + "icon": "file-text", + "sort_order": 3 + } + }, + { + "model": "content.service", + "pk": 4, + "fields": { + "title": "И��ледование биоп�ийно-операционного материала", + "slug": "biopsy-operative-material", + "short_description": "И��ледование биоп�ийно-операционного материала органов и тканей при во�палительных проце��ах, ди�трофи�х, патологии артерий/вен, пороках развити�.", + "description": "И��ледование биоп�ийно-операционного материала органов и тканей при во�палительных проце��ах, ди�трофи�х, патологии артерий/вен, пороках развити�.", + "icon": "microscope", + "sort_order": 4 + } + }, + { + "model": "content.service", + "pk": 5, + "fields": { + "title": "И��ледование операционного материала", + "slug": "operative-material", + "short_description": "И��ледование операционного материала органов и тканей при инфекционных заболевани�х, опухол�х ��ного ги�тогенеза без ди�плазии, гранулематозном во�палении.", + "description": "И��ледование операционного материала органов и тканей при инфекционных заболевани�х, опухол�х ��ного ги�тогенеза без ди�плазии, гранулематозном во�палении.", + "icon": "file-text", + "sort_order": 5 + } + }, + { + "model": "content.service", + "pk": 6, + "fields": { + "title": "И��ледование диагно�тиче�ких биоп�ий", + "slug": "diagnostic-biopsies", + "short_description": "И��ледование диагно�тиче�ких биоп�ий (кроме пункционных) органов и тканей при во�палении, ди�функции, ди�плазии, опухол�х, требующих уточнени� ги�тогенеза, �тепени инвазии, �тадии прогре��ировани�.", + "description": "И��ледование диагно�тиче�ких биоп�ий (кроме пункционных) органов и тканей при во�палении, ди�функции, ди�плазии, опухол�х, требующих уточнени� ги�тогенеза, �тепени инвазии, �тадии прогре��ировани�.", + "icon": "stethoscope", + "sort_order": 6 + } + }, + { + "model": "content.service", + "pk": 7, + "fields": { + "title": "И��ледование диагно�тиче�ких пункционных биоп�ий", + "slug": "diagnostic-puncture-biopsies", + "short_description": "И��ледование диагно�тиче�ких пункционных биоп�ий различных органов и тканей, биоп�ий и операционный материал при иммунопатологиче�ких проце��ах, опухол�х и опухолеподобных поражени�х.", + "description": "И��ледование диагно�тиче�ких пункционных биоп�ий различных органов и тканей, биоп�ий и операционный материал при иммунопатологиче�ких проце��ах, опухол�х и опухолеподобных поражени�х.", + "icon": "microscope", + "sort_order": 7 + } + }, + { + "model": "content.service", + "pk": 8, + "fields": { + "title": "Эк�пре��-и��ледование диагно�тиче�ких биоп�ий", + "slug": "express-diagnostic-biopsies", + "short_description": "Эк�пре��-и��ледование диагно�тиче�ких биоп�ий (кроме пункционных) органов и тканей при во�палении, ди�функции, ди�плазии, опухол�х, требующих уточнени� ги�тогенеза, �тепени инвазии, �тадии прогре��ировани�.", + "description": "Эк�пре��-и��ледование диагно�тиче�ких биоп�ий (кроме пункционных) органов и тканей при во�палении, ди�функции, ди�плазии, опухол�х, требующих уточнени� ги�тогенеза, �тепени инвазии, �тадии прогре��ировани�.", + "icon": "stethoscope", + "sort_order": 8 + } + }, + { + "model": "content.service", + "pk": 9, + "fields": { + "title": "И��ледование ко�тно-хр�щевых тканей", + "slug": "bone-cartilage-tissues", + "short_description": "И��ледование ко�тно-хр�щевых тканей, включа� декальцинацию операционного материала.", + "description": "И��ледование ко�тно-хр�щевых тканей, включа� декальцинацию операционного материала.", + "icon": "file-text", + "sort_order": 9 + } + }, + { + "model": "content.service", + "pk": 10, + "fields": { + "title": "Ги�тологиче�кое и��ледование по�ледов", + "slug": "placenta-histology", + "short_description": "Ги�тологиче�кое и��ледование по�ледов � целью диагно�тики внутриутробного инфицировани�.", + "description": "Ги�тологиче�кое и��ледование по�ледов � целью диагно�тики внутриутробного инфицировани�.", + "icon": "microscope", + "sort_order": 10 + } + }, + { + "model": "content.doctor", + "pk": 1, + "fields": { + "full_name": "Чуков Сергей Залимович", + "position": "Заведующий отделением, врач-патологоанатом", + "credentials": "Сертификат �пециали�та 2514 от 24.12.2018", + "bio": "Руководит отделением и уча�твует в диагно�тике �ложных клиниче�ких �лучаев.", + "photo": "", + "sort_order": 1 + } + }, + { + "model": "content.doctor", + "pk": 2, + "fields": { + "full_name": "Балабеков �ндрей Вагифович", + "position": "Врач-патологоанатом, генеральный директор", + "credentials": "Сертификат �пециали�та 307 от 07.03.2019", + "bio": "Координирует работу бюро и уча�твует в развитии диагно�тиче�кой �лужбы.", + "photo": "", + "sort_order": 2 + } + }, + { + "model": "content.doctor", + "pk": 3, + "fields": { + "full_name": "Громова Юли� Владимировна", + "position": "Врач-патологоанатом", + "credentials": "Сертификат ВСБ № 0947855 от 30.09.2006", + "bio": "Работает � морфологиче�кой диагно�тикой и �опровождением и��ледований.", + "photo": "", + "sort_order": 3 + } + }, + { + "model": "content.doctor", + "pk": 4, + "fields": { + "full_name": "Чеккуева Зульфи� �лиевна", + "position": "Врач-патологоанатом", + "credentials": "Сертификат �пециали�та 530 от 21.04.2021 г. Диплом об окончании ординатуры 1163 от 31.08.2020 г.", + "bio": "Врач-патологоанатом.", + "photo": "", + "sort_order": 4 + } + }, + { + "model": "content.document", + "pk": 1, + "fields": { + "title": "Лицензи� на о�уще�твление медицин�кой де�тельно�ти", + "category": "Лицензии", + "description": "Лицензи� № ЛО-26-01-005362 от 20 марта 2020 года.", + "file_url": "", + "sort_order": 1 + } + }, + { + "model": "content.document", + "pk": 2, + "fields": { + "title": "Территориальна� программа ОМС СК", + "category": "Документы", + "description": "Об�зательна� информаци� дл� пациентов и партнеров.", + "file_url": "", + "sort_order": 2 + } + } +] diff --git a/backend/content/migrations/0001_initial.py b/backend/content/migrations/0001_initial.py new file mode 100644 index 0000000..bf0d694 --- /dev/null +++ b/backend/content/migrations/0001_initial.py @@ -0,0 +1,129 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ContactMessage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255)), + ("phone", models.CharField(blank=True, max_length=64)), + ("email", models.EmailField(blank=True, max_length=254)), + ("message", models.TextField()), + ("is_processed", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Сообщение", + "verbose_name_plural": "Сообщени�", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Doctor", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("full_name", models.CharField(max_length=255)), + ("position", models.CharField(max_length=255)), + ("credentials", models.TextField(blank=True)), + ("bio", models.TextField(blank=True)), + ("photo", models.URLField(blank=True)), + ("sort_order", models.PositiveIntegerField(default=0)), + ], + options={ + "verbose_name": "Специали�т", + "verbose_name_plural": "Специали�ты", + "ordering": ["sort_order", "full_name"], + }, + ), + migrations.CreateModel( + name="Document", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("category", models.CharField(blank=True, max_length=120)), + ("description", models.TextField(blank=True)), + ("file_url", models.URLField(blank=True)), + ("sort_order", models.PositiveIntegerField(default=0)), + ], + options={ + "verbose_name": "Документ", + "verbose_name_plural": "Документы", + "ordering": ["sort_order", "title"], + }, + ), + migrations.CreateModel( + name="Page", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("slug", models.SlugField(unique=True)), + ("intro", models.TextField(blank=True)), + ("body", models.TextField()), + ("meta_title", models.CharField(blank=True, max_length=255)), + ("meta_description", models.CharField(blank=True, max_length=320)), + ("is_published", models.BooleanField(default=True)), + ("nav_order", models.PositiveIntegerField(default=0)), + ], + options={ + "verbose_name": "Страница", + "verbose_name_plural": "Страницы", + "ordering": ["nav_order", "title"], + }, + ), + migrations.CreateModel( + name="Service", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("slug", models.SlugField(unique=True)), + ("short_description", models.TextField()), + ("description", models.TextField()), + ("icon", models.CharField(blank=True, max_length=64)), + ("sort_order", models.PositiveIntegerField(default=0)), + ], + options={ + "verbose_name": "У�луга", + "verbose_name_plural": "У�луги", + "ordering": ["sort_order", "title"], + }, + ), + migrations.CreateModel( + name="SiteSettings", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("site_name", models.CharField(max_length=255)), + ("tagline", models.CharField(blank=True, max_length=255)), + ("phone", models.CharField(max_length=64)), + ("email", models.EmailField(blank=True, max_length=254)), + ("address", models.CharField(max_length=255)), + ("city", models.CharField(default="Ставрополь", max_length=120)), + ("working_hours", models.CharField(blank=True, max_length=255)), + ("hero_title", models.CharField(max_length=255)), + ("hero_subtitle", models.TextField()), + ("hero_image", models.URLField(blank=True)), + ("about_excerpt", models.TextField(blank=True)), + ], + options={ + "verbose_name": "�а�тройки �айта", + "verbose_name_plural": "�а�тройки �айта", + }, + ), + ] + diff --git a/backend/content/migrations/__init__.py b/backend/content/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/content/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/content/models.py b/backend/content/models.py new file mode 100644 index 0000000..fd0dc37 --- /dev/null +++ b/backend/content/models.py @@ -0,0 +1,116 @@ +from django.db import models + + +class TimestampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class SiteSettings(TimestampedModel): + site_name = models.CharField(max_length=255) + tagline = models.CharField(max_length=255, blank=True) + phone = models.CharField(max_length=64) + email = models.EmailField(blank=True) + address = models.CharField(max_length=255) + city = models.CharField(max_length=120, default="Ставрополь") + working_hours = models.CharField(max_length=255, blank=True) + hero_title = models.CharField(max_length=255) + hero_subtitle = models.TextField() + hero_image = models.URLField(blank=True) + about_excerpt = models.TextField(blank=True) + + class Meta: + verbose_name = "�а�тройки �айта" + verbose_name_plural = "�а�тройки �айта" + + def __str__(self): + return self.site_name + + +class Page(TimestampedModel): + title = models.CharField(max_length=255) + slug = models.SlugField(unique=True) + intro = models.TextField(blank=True) + body = models.TextField() + meta_title = models.CharField(max_length=255, blank=True) + meta_description = models.CharField(max_length=320, blank=True) + is_published = models.BooleanField(default=True) + nav_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["nav_order", "title"] + verbose_name = "Страница" + verbose_name_plural = "Страницы" + + def __str__(self): + return self.title + + +class Service(TimestampedModel): + title = models.CharField(max_length=255) + slug = models.SlugField(unique=True) + short_description = models.TextField() + description = models.TextField() + icon = models.CharField(max_length=64, blank=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "title"] + verbose_name = "У�луга" + verbose_name_plural = "У�луги" + + def __str__(self): + return self.title + + +class Doctor(TimestampedModel): + full_name = models.CharField(max_length=255) + position = models.CharField(max_length=255) + credentials = models.TextField(blank=True) + bio = models.TextField(blank=True) + photo = models.URLField(blank=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "full_name"] + verbose_name = "Специали�т" + verbose_name_plural = "Специали�ты" + + def __str__(self): + return self.full_name + + +class Document(TimestampedModel): + title = models.CharField(max_length=255) + category = models.CharField(max_length=120, blank=True) + description = models.TextField(blank=True) + file_url = models.URLField(blank=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "title"] + verbose_name = "Документ" + verbose_name_plural = "Документы" + + def __str__(self): + return self.title + + +class ContactMessage(TimestampedModel): + name = models.CharField(max_length=255) + phone = models.CharField(max_length=64, blank=True) + email = models.EmailField(blank=True) + message = models.TextField() + is_processed = models.BooleanField(default=False) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Сообщение" + verbose_name_plural = "Сообщени�" + + def __str__(self): + return f"{self.name} ({self.created_at:%d.%m.%Y})" + diff --git a/backend/content/serializers.py b/backend/content/serializers.py new file mode 100644 index 0000000..ccfad21 --- /dev/null +++ b/backend/content/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers + +from .models import ContactMessage, Doctor, Document, Page, Service, SiteSettings + + +class SiteSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = SiteSettings + fields = "__all__" + + +class PageSerializer(serializers.ModelSerializer): + class Meta: + model = Page + fields = "__all__" + + +class ServiceSerializer(serializers.ModelSerializer): + class Meta: + model = Service + fields = "__all__" + + +class DoctorSerializer(serializers.ModelSerializer): + class Meta: + model = Doctor + fields = "__all__" + + +class DocumentSerializer(serializers.ModelSerializer): + class Meta: + model = Document + fields = "__all__" + + +class ContactMessageSerializer(serializers.ModelSerializer): + class Meta: + model = ContactMessage + fields = ("id", "name", "phone", "email", "message", "created_at") + read_only_fields = ("id", "created_at") + diff --git a/backend/content/urls.py b/backend/content/urls.py new file mode 100644 index 0000000..24ebeec --- /dev/null +++ b/backend/content/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .views import ( + ContactMessageCreateAPIView, + DoctorListAPIView, + DocumentListAPIView, + HomeAPIView, + PageDetailAPIView, + PageListAPIView, + ServiceDetailAPIView, + ServiceListAPIView, + SiteSettingsAPIView, +) + +urlpatterns = [ + path("home/", HomeAPIView.as_view(), name="home"), + path("site/", SiteSettingsAPIView.as_view(), name="site-settings"), + path("pages/", PageListAPIView.as_view(), name="page-list"), + path("pages//", PageDetailAPIView.as_view(), name="page-detail"), + path("services/", ServiceListAPIView.as_view(), name="service-list"), + path("services//", ServiceDetailAPIView.as_view(), name="service-detail"), + path("doctors/", DoctorListAPIView.as_view(), name="doctor-list"), + path("documents/", DocumentListAPIView.as_view(), name="document-list"), + path("contact/", ContactMessageCreateAPIView.as_view(), name="contact-create"), +] + diff --git a/backend/content/views.py b/backend/content/views.py new file mode 100644 index 0000000..01d0d2b --- /dev/null +++ b/backend/content/views.py @@ -0,0 +1,70 @@ +from rest_framework import generics +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import ContactMessage, Doctor, Document, Page, Service, SiteSettings +from .serializers import ( + ContactMessageSerializer, + DoctorSerializer, + DocumentSerializer, + PageSerializer, + ServiceSerializer, + SiteSettingsSerializer, +) + + +class HomeAPIView(APIView): + def get(self, request): + settings_obj = SiteSettings.objects.order_by("id").first() + data = { + "site": SiteSettingsSerializer(settings_obj).data if settings_obj else None, + "pages": PageSerializer(Page.objects.filter(is_published=True)[:6], many=True).data, + "services": ServiceSerializer(Service.objects.all()[:6], many=True).data, + "doctors": DoctorSerializer(Doctor.objects.all(), many=True).data, + "documents": DocumentSerializer(Document.objects.all()[:4], many=True).data, + } + return Response(data) + + +class SiteSettingsAPIView(generics.RetrieveAPIView): + serializer_class = SiteSettingsSerializer + + def get_object(self): + return SiteSettings.objects.order_by("id").first() + + +class PageListAPIView(generics.ListAPIView): + serializer_class = PageSerializer + queryset = Page.objects.filter(is_published=True) + + +class PageDetailAPIView(generics.RetrieveAPIView): + serializer_class = PageSerializer + lookup_field = "slug" + queryset = Page.objects.filter(is_published=True) + + +class ServiceListAPIView(generics.ListAPIView): + serializer_class = ServiceSerializer + queryset = Service.objects.all() + + +class ServiceDetailAPIView(generics.RetrieveAPIView): + serializer_class = ServiceSerializer + lookup_field = "slug" + queryset = Service.objects.all() + + +class DoctorListAPIView(generics.ListAPIView): + serializer_class = DoctorSerializer + queryset = Doctor.objects.all() + + +class DocumentListAPIView(generics.ListAPIView): + serializer_class = DocumentSerializer + queryset = Document.objects.all() + + +class ContactMessageCreateAPIView(generics.CreateAPIView): + serializer_class = ContactMessageSerializer + queryset = ContactMessage.objects.all() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..7d612e5 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9f6ffd7 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +Django==5.1.6 +djangorestframework==3.15.2 +django-cors-headers==4.6.0 +psycopg[binary]==3.2.3 +python-dotenv==1.0.1 +gunicorn==23.0.0 +Pillow==11.0.0 + diff --git a/deploy/nginx/kkpab.conf b/deploy/nginx/kkpab.conf new file mode 100644 index 0000000..5586792 --- /dev/null +++ b/deploy/nginx/kkpab.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name kkpab26.ru www.kkpab26.ru; + + client_max_body_size 20m; + + location /static/ { + alias /var/www/kkpab/backend/staticfiles/; + } + + location /media/ { + alias /var/www/kkpab/backend/media/; + } + + location /admin/ { + proxy_pass http://127.0.0.1:7788; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://127.0.0.1:7788; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/systemd/kkpab-backend.service b/deploy/systemd/kkpab-backend.service new file mode 100644 index 0000000..bbcde7d --- /dev/null +++ b/deploy/systemd/kkpab-backend.service @@ -0,0 +1,14 @@ +[Unit] +Description=KKPAB Django API +After=network.target + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/var/www/kkpab/backend +EnvironmentFile=/var/www/kkpab/backend/.env +ExecStart=/var/www/kkpab/backend/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:7788 +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/kkpab-frontend.service b/deploy/systemd/kkpab-frontend.service new file mode 100644 index 0000000..f7364fd --- /dev/null +++ b/deploy/systemd/kkpab-frontend.service @@ -0,0 +1,14 @@ +[Unit] +Description=KKPAB Next.js frontend +After=network.target + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/var/www/kkpab/frontend +EnvironmentFile=/var/www/kkpab/frontend/.env.local +ExecStart=/usr/bin/npm run start +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e54a542 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +name: kkpab-site-old + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: kkpab + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + restart: unless-stopped + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5433:5432" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - ./backend/.env + volumes: + - ./backend:/app + ports: + - "7788:8000" + depends_on: + - db + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - ./frontend/.env.local + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + ports: + - "3000:3000" + depends_on: + - backend + +volumes: + postgres_data: diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..772faf1 --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:7788/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0dbf9aa --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-bookworm-slim + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] + diff --git a/frontend/XXAAiGlh b/frontend/XXAAiGlh new file mode 100644 index 0000000..8829dec Binary files /dev/null and b/frontend/XXAAiGlh differ diff --git a/frontend/XXOIHCKc b/frontend/XXOIHCKc new file mode 100644 index 0000000..8829dec Binary files /dev/null and b/frontend/XXOIHCKc differ diff --git a/frontend/XXdhDoOp b/frontend/XXdhDoOp new file mode 100644 index 0000000..586efdc Binary files /dev/null and b/frontend/XXdhDoOp differ diff --git a/frontend/XXkPFHOi b/frontend/XXkPFHOi new file mode 100644 index 0000000..8829dec Binary files /dev/null and b/frontend/XXkPFHOi differ diff --git a/frontend/app/documents/page.tsx b/frontend/app/documents/page.tsx new file mode 100644 index 0000000..43de137 --- /dev/null +++ b/frontend/app/documents/page.tsx @@ -0,0 +1,189 @@ +import Link from "next/link"; +import { Download, ExternalLink, Eye, FileText } from "lucide-react"; + +type DocumentItem = { + id: number; + title: string; + file?: string | string[]; + mode: "preview" | "download" | "disabled"; +}; + +const documentItems: DocumentItem[] = [ + { id: 1, title: "Скачать договор на об�луживание", file: "doc_on_out.doc", mode: "download" }, + { id: 2, title: "Правила о предо�тавлении платных у�луг", file: "pravila_o_pred.pdf", mode: "preview" }, + { id: 3, title: "Перечень платных медицин�ких у�луг", file: "price_site.pdf", mode: "preview" }, + { id: 4, title: "Лицензи�", file: "lic.pdf", mode: "preview" }, + { id: 5, title: "Правила внутреннего ра�пор�дка", file: "Indoor_rule.pdf", mode: "preview" }, + { id: 6, title: "Лекар�твенное обе�печение", file: "jnvlp.pdf", mode: "preview" }, + { id: 7, title: "О правилах и �роках го�питализации", file: "o_pravilah_gospitalizacii.pdf", mode: "preview" }, + { id: 8, title: "Территориальна� программа ОМС СК", file: "ter-prog.pdf", mode: "preview" }, + { id: 9, title: "О правилах подготовки к диагно�тиче�ким и��ледовани�м", file: "o_diagnostike.pdf", mode: "preview" }, + { id: 10, title: "О пор�дке, объеме и у�лови�х оказани� медицин�кой помощи", file: "ob_obyome.pdf", mode: "preview" }, + { id: 11, title: "Правила запи�и на первичный прием", file: "pravilo_zapisi_na_perviy_priem.pdf", mode: "preview" }, + { id: 12, title: "О го�удар�твенной поддержке развити� медицин�кой промышленно�ти", file: "postanovlenie.pdf", mode: "preview" }, + { id: 13, title: "Информаци� дл� пациентов", file: ["pacient1.pdf", "pacient2.pdf"], mode: "preview" }, + { id: 14, title: "�ормативно-правовые документы", file: ["npd1.pdf", "npd2.pdf", "npd3.pdf", "npd5.pdf", "npd7.pdf", "npd8.pdf", "npd9.pdf", "npd10.pdf"], mode: "preview" }, + { id: 15, title: "Права и об�занно�ти гражданина в �фере охраны здоровь�", file: "piog.pdf", mode: "preview" }, + { id: 16, title: "Перечень Ж�ВЛП", file: "jnvlp.pdf", mode: "preview" }, + { id: 17, title: "Работа � пер�ональными данными", file: "rpd.pdf", mode: "preview" }, +]; + +export default async function DocumentsPage({ + searchParams, +}: { + // Добавл�ем параметр fileIndex дл� от�леживани� выбранного файла в ма��иве + searchParams: Promise<{ doc?: string; fileIndex?: string }>; +}) { + const params = await searchParams; + const selectedId = Number(params.doc); + const selectedIndex = Number(params.fileIndex) || 0; // По умолчанию 0 (первый файл) + + const firstPreviewable = + documentItems.find((item) => item.mode === "preview" && item.file) ?? null; + const selectedItem = + documentItems.find((item) => item.id === selectedId && item.mode === "preview" && item.file) ?? + firstPreviewable; + + // Логика определени� конкретного файла дл� отображени� + let fileForLink: string | null = null; + let previewUrl: string | null = null; + + if (selectedItem?.file) { + if (Array.isArray(selectedItem.file)) { + // Защита от неверного индек�а в URL + const validIndex = selectedIndex >= 0 && selectedIndex < selectedItem.file.length ? selectedIndex : 0; + fileForLink = selectedItem.file[validIndex]; + } else { + fileForLink = selectedItem.file; + } + previewUrl = `/documents/${fileForLink}#toolbar=0&navpanes=0&scrollbar=1`; + } + + return ( +
+
+ Документы +

Лицензии и официальные �ведени�

+

+ Дл� удоб�тва документы переведены в PDF-формат дл� в�троенного про�мотра. Первый пункт + о�таёт�� отдельной кнопкой �качивани� договора. +

+
+ +
+
+ + +
+
+
+
+ Про�мотр документа +

{selectedItem?.title ?? "Документ не выбран"}

+ + {/* Вывод �пи�ка кнопок, е�ли файлов не�колько */} + {selectedItem?.file && Array.isArray(selectedItem.file) && selectedItem.file.length > 1 && ( +
+ {selectedItem.file.map((fileName, idx) => { + const validCurrentIndex = selectedIndex < selectedItem.file.length ? selectedIndex : 0; + const isActive = validCurrentIndex === idx; + + return ( + + + Файл {idx + 1} + + ); + })} +
+ )} +
+ + {fileForLink ? ( + + + Открыть в отдельной вкладке + + ) : ( +
+ + Документ недо�тупен +
+ )} +
+ + {previewUrl ? ( +
+