diff --git a/.vscode/launch.json b/.vscode/launch.json index f677f73c..5107751f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,28 +8,28 @@ "name": "Run", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/rsconcept/RunServer.ps1", + "script": "${workspaceFolder}/scripts/dev/RunServer.ps1", "args": [] }, { "name": "Lint", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/rsconcept/RunLint.ps1", + "script": "${workspaceFolder}/scripts/dev/RunLint.ps1", "args": [] }, { "name": "Test", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/rsconcept/RunTests.ps1", + "script": "${workspaceFolder}/scripts/dev/RunTests.ps1", "args": [] }, { "name": "BE-Coverage", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/rsconcept/RunCoverage.ps1", + "script": "${workspaceFolder}/scripts/dev/RunCoverage.ps1", "args": [] }, { @@ -57,7 +57,7 @@ "name": "Restart", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/rsconcept/RunServer.ps1", + "script": "${workspaceFolder}/scripts/dev/RunServer.ps1", "args": ["-freshStart"] }, { diff --git a/README.md b/README.md index 062a43b8..fe8f0aca 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,11 @@ React + Django based web portal for editing RSForm schemas. This readme file is used mostly to document project dependencies - -# Developer Setup Notes -- Install Python 3.9, NodeJS, VSCode, Docker Desktop -- copy import wheels from ConceptCore to rsconcept\backend\import -- run rsconcept\backend\LocalEnvSetup.ps1 -- run 'npm install' in rsconcept\frontend -- use VSCode configs in root folder to start developement -- production: create secrets secrets\db_password.txt and django_key.txt -- production: provide TLS certificate nginx\cert\portal-cert.pem and nginx\cert\portal-key.pem - # Contributing notes +!BEFORE PUSHING INTO MAIN! - use Test config in VSCode to run tests before pushing commits / requests -- !BEFORE PUSHING INTO MAIN! in rsconcept\frontend run in terminal 'npm run build' and fix all errors -- when making major changes make sure that Docker production is building correctly. run 'docker compose -f docker-compose-prod.yml up' +- cd rsconcept/frontend & npm run build +- docker compose -f docker-compose-prod.yml up # Frontend stack & Tooling [Vite + React + Typescript]
@@ -62,11 +53,11 @@ This readme file is used mostly to document project dependencies
requirements
-  - tzdata
   - django
   - djangorestframework
   - django-cors-headers
   - django-filter
+  - tzdata
   - gunicorn
   - coreapi
   - psycopg2-binary
@@ -96,3 +87,32 @@ This readme file is used mostly to document project dependencies
 # DevOps
 - Docker compose
 - PowerShell
+- Certbot
+- Docker VSCode extension
+
+# Developer Notes
+## Local build (Windows 10+)
+- this is main developers build
+- Install Python 3.9, NodeJS, VSCode, Docker Desktop
+- copy import wheels from ConceptCore to rsconcept/backend/import
+- run rsconcept/backend/LocalEnvSetup.ps1
+- run 'npm install' in rsconcept/frontend
+- use VSCode configs in root folder to start developement
+
+## Developement build
+- this build does not use HTTPS and nginx for networking
+- backend and frontend debugging is supported
+- hmr (hot updates) for frontend
+- run via 'docker compose -f "docker-compose-dev.yml" up --build -d'
+- populate initial data: rsconcept/PopulateDevData.ps1 dev-portal-backend
+
+## Local production build
+- this build is same as production except not using production secrets and working on localhost
+- provide TLS certificate (can be self-signed) 'nginx/cert/local-cert.pem' and 'nginx/cert/local-key.pem'
+- run via 'docker compose -f "docker-compose-prod-local.yml" up --build -d'
+- populate initial data: rsconcept/PopulateDevData.ps1 local-portal-backend
+
+## Production build
+- create secrets secrets/db_password.txt and django_key.txt
+- provide TLS certificate 'nginx/cert/front-cert.pem' and 'nginx/cert/front-key.pem'
+- run via 'docker compose -f "docker-compose-prod.yml" up --build -d'
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
new file mode 100644
index 00000000..e2c34974
--- /dev/null
+++ b/docker-compose-dev.yml
@@ -0,0 +1,55 @@
+name: dev-concept-portal
+
+volumes:
+  postgres_volume:
+    name: "dev-portal-data"
+  django_static_volume:
+    name: "dev-portal-static"
+  django_media_volume:
+    name: "dev-portal-media"
+
+networks:
+  default:
+    name: dev-concept-api-net
+
+services:
+  frontend:
+    container_name: dev-portal-frontend
+    restart: always
+    depends_on:
+      - backend
+    build: 
+      context: ./rsconcept/frontend
+      dockerfile: Dockerfile.dev
+      args:
+        BUILD_TYPE: development
+    ports:
+      - 3002:3002
+    command: npm run dev -- --host
+
+
+  backend:
+    container_name: dev-portal-backend
+    restart: always
+    depends_on:
+      - postgresql-db
+    build:
+      context: ./rsconcept/backend
+    env_file: ./rsconcept/backend/.env.dev
+    ports:
+      - 8002:8002
+    volumes:
+      - django_static_volume:/home/app/web/static
+      - django_media_volume:/home/app/web/media
+    command:
+      gunicorn -w 3 project.wsgi --bind 0.0.0.0:8002
+
+
+  postgresql-db:
+    container_name: dev-portal-db
+    restart: always
+    image: postgres:alpine
+    env_file: ./postgresql/.env.dev
+    volumes:
+      - postgres_volume:/var/lib/postgresql/data
+
diff --git a/docker-compose-prod-local.yml b/docker-compose-prod-local.yml
new file mode 100644
index 00000000..f605ad05
--- /dev/null
+++ b/docker-compose-prod-local.yml
@@ -0,0 +1,71 @@
+name: local-concept-portal
+
+volumes:
+  postgres_volume:
+    name: "local-portal-data"
+  django_static_volume:
+    name: "local-portal-static"
+  django_media_volume:
+    name: "local-portal-media"
+
+networks:
+  default:
+    name: local-concept-api-net
+
+services:
+  frontend:
+    container_name: local-portal-frontend
+    restart: always
+    depends_on:
+      - backend
+    build: 
+      context: ./rsconcept/frontend
+      args:
+        BUILD_TYPE: production.local
+    expose:
+      - 3001
+    command: serve -s /home/node -l 3001
+
+
+  backend:
+    container_name: local-portal-backend
+    restart: always
+    depends_on:
+      - postgresql-db
+    build:
+      context: ./rsconcept/backend
+    env_file: ./rsconcept/backend/.env.prod.local
+    expose:
+      - 8001
+    volumes:
+      - django_static_volume:/home/app/web/static
+      - django_media_volume:/home/app/web/media
+    command:
+      gunicorn -w 3 project.wsgi --bind 0.0.0.0:8001
+
+
+  postgresql-db:
+    container_name: local-portal-db
+    restart: always
+    image: postgres:alpine
+    env_file: ./postgresql/.env.prod.local
+    volumes:
+      - postgres_volume:/var/lib/postgresql/data
+
+
+  nginx:
+    container_name: local-portal-router
+    restart: always
+    build:
+      context: ./nginx
+      args:
+        BUILD_TYPE: production.local
+    ports:
+      - 8001:8001
+      - 3001:3001
+    depends_on:
+      - backend
+    command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
+    volumes:
+      - django_static_volume:/var/www/static
+      - django_media_volume:/var/www/media
diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml
index 465bae31..8edcdd4d 100644
--- a/docker-compose-prod.yml
+++ b/docker-compose-prod.yml
@@ -26,6 +26,8 @@ services:
       - backend
     build: 
       context: ./rsconcept/frontend
+      args:
+        BUILD_TYPE: production
     expose:
       - 3000
     command: serve -s /home/node -l 3000
@@ -72,6 +74,8 @@ services:
     restart: always
     build:
       context: ./nginx
+      args:
+        BUILD_TYPE: production
     ports:
       - 8000:8000
       - 3000:3000
diff --git a/nginx/Dockerfile b/nginx/Dockerfile
index 52307f08..26799954 100644
--- a/nginx/Dockerfile
+++ b/nginx/Dockerfile
@@ -1,5 +1,6 @@
 FROM nginx:stable-alpine3.17-slim
+ARG BUILD_TYPE=production
 
 # Сopу nginx configuration to the proxy-server
-COPY ./default.conf /etc/nginx/conf.d/default.conf
+COPY ./$BUILD_TYPE.conf /etc/nginx/conf.d/default.conf
 COPY ./cert/* /etc/ssl/private/
\ No newline at end of file
diff --git a/nginx/default.conf b/nginx/production.conf
similarity index 89%
rename from nginx/default.conf
rename to nginx/production.conf
index 2e2e0032..7f666e68 100644
--- a/nginx/default.conf
+++ b/nginx/production.conf
@@ -10,7 +10,7 @@ server {
     listen 8000 ssl;
     ssl_certificate /etc/ssl/private/front-cert.pem;
     ssl_certificate_key /etc/ssl/private/front-key.pem;
-    server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru api.portal.acconcept.ru www.api.portal.acconcept.ru mail.acconcept.ru www.mail.acconcept.ru;
+    server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru api.portal.acconcept.ru www.api.portal.acconcept.ru;
 
     location / {
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -30,7 +30,7 @@ server {
     listen 3000 ssl;
     ssl_certificate /etc/ssl/private/front-cert.pem;
     ssl_certificate_key /etc/ssl/private/front-key.pem;
-    server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru mail.acconcept.ru www.mail.acconcept.ru;
+    server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru;
     
     location / {
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/nginx/production.local.conf b/nginx/production.local.conf
new file mode 100644
index 00000000..68c61b84
--- /dev/null
+++ b/nginx/production.local.conf
@@ -0,0 +1,41 @@
+upstream innerdjango {
+    server backend:8001;
+}
+
+upstream innerreact {
+    server frontend:3001;
+}
+
+server {
+    listen 8001 ssl;
+    ssl_certificate /etc/ssl/private/local-cert.pem;
+    ssl_certificate_key /etc/ssl/private/local-key.pem;
+    server_name localhost;
+
+    location / {
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header Host $host;
+        proxy_pass http://innerdjango;
+        proxy_redirect default;
+    }
+    location /static/ {
+        alias /var/www/static/;
+    }
+    location /media/ {
+        alias /var/www/media/;
+   }
+}
+
+server {
+    listen 3001 ssl;
+    ssl_certificate /etc/ssl/private/local-cert.pem;
+    ssl_certificate_key /etc/ssl/private/local-key.pem;
+    server_name localhost;
+    
+    location / {
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header Host $host;
+        proxy_pass http://innerreact;
+        proxy_redirect default;
+    }
+}
\ No newline at end of file
diff --git a/postgresql/.env.dev b/postgresql/.env.dev
new file mode 100644
index 00000000..f7e1745f
--- /dev/null
+++ b/postgresql/.env.dev
@@ -0,0 +1,5 @@
+# WARNING! This config does not use 'real' production values for secrets
+# DO NOT use PRODUCTION LOCAL build for deployment!
+POSTGRES_USER=portal-admin
+POSTGRES_DB=portal-db
+POSTGRES_PASSWORD=78ACF6C4F3
\ No newline at end of file
diff --git a/postgresql/.env.prod.local b/postgresql/.env.prod.local
new file mode 100644
index 00000000..f7e1745f
--- /dev/null
+++ b/postgresql/.env.prod.local
@@ -0,0 +1,5 @@
+# WARNING! This config does not use 'real' production values for secrets
+# DO NOT use PRODUCTION LOCAL build for deployment!
+POSTGRES_USER=portal-admin
+POSTGRES_DB=portal-db
+POSTGRES_PASSWORD=78ACF6C4F3
\ No newline at end of file
diff --git a/rsconcept/RunCoverage.ps1 b/rsconcept/RunCoverage.ps1
deleted file mode 100644
index 15dc7461..00000000
--- a/rsconcept/RunCoverage.ps1
+++ /dev/null
@@ -1,12 +0,0 @@
- # Run coverage analysis
-Set-Location $PSScriptRoot\backend
-
-$coverageExec = "$PSScriptRoot\backend\venv\Scripts\coverage.exe"
-$djangoSrc = "$PSScriptRoot\backend\manage.py"
-$exclude = '*/venv/*,*/tests/*,*/migrations/*,*__init__.py,manage.py,apps.py,urls.py,settings.py'
-
-& $coverageExec run --omit=$exclude $djangoSrc test
-& $coverageExec report
-& $coverageExec html
-
-Start-Process "file:///$PSScriptRoot\backend\htmlcov\index.html"
\ No newline at end of file
diff --git a/rsconcept/RunLint.ps1 b/rsconcept/RunLint.ps1
deleted file mode 100644
index c47debec..00000000
--- a/rsconcept/RunLint.ps1
+++ /dev/null
@@ -1,8 +0,0 @@
- # Run coverage analysis
-Set-Location $PSScriptRoot\backend
-
-$pylint = "$PSScriptRoot\backend\venv\Scripts\pylint.exe"
-$mypy = "$PSScriptRoot\backend\venv\Scripts\mypy.exe"
-
-& $pylint cctext project apps
-& $mypy cctext project apps
\ No newline at end of file
diff --git a/rsconcept/RunTests.ps1 b/rsconcept/RunTests.ps1
deleted file mode 100644
index 571d647f..00000000
--- a/rsconcept/RunTests.ps1
+++ /dev/null
@@ -1,12 +0,0 @@
- # Run tests
-Set-Location $PSScriptRoot\backend
-
-$pyExec = "$PSScriptRoot\backend\venv\Scripts\python.exe"
-$djangoSrc = "$PSScriptRoot\backend\manage.py"
-
-& $pyExec $djangoSrc check
-& $pyExec $djangoSrc test
-
-Set-Location $PSScriptRoot\frontend
-
-& npm test
\ No newline at end of file
diff --git a/rsconcept/backend/.env.dev b/rsconcept/backend/.env.dev
new file mode 100644
index 00000000..501f8a22
--- /dev/null
+++ b/rsconcept/backend/.env.dev
@@ -0,0 +1,27 @@
+# Application settings
+# WARNING! This config does not use 'real' production values for secrets
+# DO NOT use PRODUCTION LOCAL build for deployment!
+SECRET_KEY=s-55j!5jlan=x%8-6m1qnst^7s6nwby4dx@vei)5w8t)3_=mv1
+ALLOWED_HOSTS=localhost
+CSRF_TRUSTED_ORIGINS=http://localhost:3002;http://localhost:8002
+CORS_ALLOWED_ORIGINS=http://localhost:3002
+
+
+# File locations
+STATIC_ROOT=/home/app/web/static
+MEDIA_ROOT=/home/app/web/media
+
+
+# Database settings
+DB_ENGINE=django.db.backends.postgresql_psycopg2
+DB_NAME=portal-db
+DB_USER=portal-admin
+DB_HOST=postgresql-db
+DB_PORT=5432
+DB_PASSWORD=78ACF6C4F3
+
+
+# Debug settings
+DEBUG=1
+PYTHONDEVMODE=1
+PYTHONTRACEMALLOC=1
\ No newline at end of file
diff --git a/rsconcept/backend/.env.prod b/rsconcept/backend/.env.prod
index 46551abe..b2e5109f 100644
--- a/rsconcept/backend/.env.prod
+++ b/rsconcept/backend/.env.prod
@@ -1,5 +1,6 @@
 # Application settings
 
+# SECRET_KEY=
 ALLOWED_HOSTS=portal.acconcept.ru;dev.concept.ru
 CSRF_TRUSTED_ORIGINS=https://dev.concept.ru:3000;https://dev.concept.ru:8000;https://portal.acconcept.ru;https://portal.acconcept.ru:8081;https://portal.acconcept.ru:8082
 CORS_ALLOWED_ORIGINS=https://dev.concept.ru:3000;https://portal.acconcept.ru;https://portal.acconcept.ru:8081
@@ -16,6 +17,7 @@ DB_NAME=portal-db
 DB_USER=portal-admin
 DB_HOST=postgresql-db
 DB_PORT=5432
+# DB_PASSWORD=
 
 
 # Debug settings
diff --git a/rsconcept/backend/.env.prod.local b/rsconcept/backend/.env.prod.local
new file mode 100644
index 00000000..da3f2e1e
--- /dev/null
+++ b/rsconcept/backend/.env.prod.local
@@ -0,0 +1,27 @@
+# Application settings
+# WARNING! This config does not use 'real' production values for secrets
+# DO NOT use PRODUCTION LOCAL build for deployment!
+SECRET_KEY=s-55j!5jlan=x%8-6m1qnst^7s6nwby4dx@vei)5w8t)3_=mv1
+ALLOWED_HOSTS=localhost
+CSRF_TRUSTED_ORIGINS=https://localhost:3001;https://localhost:8001
+CORS_ALLOWED_ORIGINS=https://localhost:3001
+
+
+# File locations
+STATIC_ROOT=/home/app/web/static
+MEDIA_ROOT=/home/app/web/media
+
+
+# Database settings
+DB_ENGINE=django.db.backends.postgresql_psycopg2
+DB_NAME=portal-db
+DB_USER=portal-admin
+DB_HOST=postgresql-db
+DB_PORT=5432
+DB_PASSWORD=78ACF6C4F3
+
+
+# Debug settings
+DEBUG=0
+PYTHONDEVMODE=0
+PYTHONTRACEMALLOC=0
\ No newline at end of file
diff --git a/rsconcept/backend/Dockerfile b/rsconcept/backend/Dockerfile
index 8b0a9661..db53d958 100644
--- a/rsconcept/backend/Dockerfile
+++ b/rsconcept/backend/Dockerfile
@@ -46,6 +46,7 @@ RUN mkdir -p $USER_HOME && \
     mkdir -p $APP_HOME && \
     mkdir -p $APP_HOME/static && \
     mkdir -p $APP_HOME/media && \
+    mkdir -p $APP_HOME/backup && \
     adduser --system --group app
 
 # Install python dependencies
@@ -61,10 +62,11 @@ COPY project/ ./project
 COPY fixtures/ ./fixtures
 COPY manage.py entrypoint.sh ./
 RUN sed -i 's/\r$//g'  $APP_HOME/entrypoint.sh && \
-    chmod +x  $APP_HOME/entrypoint.sh && \
+    chmod +x $APP_HOME/entrypoint.sh && \
     chown -R app:app $APP_HOME && \
     chmod -R a+rwx $APP_HOME/static && \
-    chmod -R a+rwx $APP_HOME/media
+    chmod -R a+rwx $APP_HOME/media && \
+    chmod -R a+rwx $APP_HOME/backup
 
 RUN 
 
diff --git a/rsconcept/backend/LocalEnvSetup.ps1 b/rsconcept/backend/LocalEnvSetup.ps1
deleted file mode 100644
index d33f6530..00000000
--- a/rsconcept/backend/LocalEnvSetup.ps1
+++ /dev/null
@@ -1,24 +0,0 @@
-# Script creates venv and installs dependencies + imports
-Set-Location $PSScriptRoot
-
-$envPath = "$PSScriptRoot\venv"
-$python = "$envPath\Scripts\python.exe"
-
-if (Test-Path -Path $envPath) {
-    Write-Host "Removing previous env: $envPath`n" -ForegroundColor DarkGreen
-    Remove-Item $envPath -Recurse -Force
-}
-
-Write-Host "Creating python env: $envPath`n" -ForegroundColor DarkGreen
-& 'python' -m venv $envPath
-& $python -m pip install --upgrade pip
-& $python -m pip install -r requirements_dev.txt
-
-$wheel = Get-Childitem -Path import\*win*.whl -Name
-if (-not $wheel) {
-    Write-Error 'Missing import wheel'
-    Exit 1
-}
-
-Write-Host "Installing wheel: $wheel`n" -ForegroundColor DarkGreen
-& $python -m pip install -I import\$wheel
\ No newline at end of file
diff --git a/rsconcept/backend/apps/rsform/models.py b/rsconcept/backend/apps/rsform/models.py
index c631a04c..ff6ff3f4 100644
--- a/rsconcept/backend/apps/rsform/models.py
+++ b/rsconcept/backend/apps/rsform/models.py
@@ -1,6 +1,4 @@
 ''' Models: RSForms for conceptual schemas. '''
-import json
-from copy import deepcopy
 import re
 from typing import Iterable, Optional, cast
 
@@ -13,7 +11,6 @@ from django.core.validators import MinValueValidator
 from django.core.exceptions import ValidationError
 from django.urls import reverse
 
-import pyconcept
 from apps.users.models import User
 from cctext import Resolver, Entity, extract_entities
 from .graph import Graph
@@ -315,6 +312,8 @@ class RSForm:
         ''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
         if position <= 0:
             raise ValidationError('Invalid position: should be positive integer')
+        currentSize = self.constituents().count()
+        position =  max(1, min(position, currentSize + 1))
         update_list = Constituenta.objects.only('id', 'order', 'schema').filter(schema=self.item, order__gte=position)
         for cst in update_list:
             cst.order += 1
@@ -326,7 +325,6 @@ class RSForm:
             alias=alias,
             cst_type=insert_type
         )
-        self.update_order()
         self.item.save()
         result.refresh_from_db()
         return result
@@ -343,7 +341,6 @@ class RSForm:
             alias=alias,
             cst_type=insert_type
         )
-        self.update_order()
         self.item.save()
         result.refresh_from_db()
         return result
@@ -369,7 +366,6 @@ class RSForm:
                 count_moved += 1
             update_list.append(cst)
         Constituenta.objects.bulk_update(update_list, ['order'])
-        self.update_order()
         self.item.save()
 
     @transaction.atomic
@@ -377,7 +373,7 @@ class RSForm:
         ''' Delete multiple constituents. Do not check if listCst are from this schema '''
         for cst in listCst:
             cst.delete()
-        self.update_order()
+        self._reset_order()
         self.resolve_all_text()
         self.item.save()
 
@@ -447,23 +443,6 @@ class RSForm:
             if modified:
                 cst.save()
 
-    @transaction.atomic
-    def update_order(self):
-        ''' Update constituents order. '''
-        checked = PyConceptAdapter(self).basic()
-        update_list = self.constituents().only('id', 'order')
-        if len(checked['items']) != update_list.count():
-            raise ValidationError('Invalid constituents count')
-        order = 1
-        for cst in checked['items']:
-            cst_id = cst['id']
-            for oldCst in update_list:
-                if oldCst.pk == cst_id:
-                    oldCst.order = order
-                    order += 1
-                    break
-        Constituenta.objects.bulk_update(update_list, ['order'])
-
     @transaction.atomic
     def resolve_all_text(self):
         ''' Trigger reference resolution for all texts. '''
@@ -482,6 +461,15 @@ class RSForm:
                 cst.definition_resolved = resolved
                 cst.save()
 
+    @transaction.atomic
+    def _reset_order(self):
+        order = 1
+        for cst in self.constituents().only('id', 'order').order_by('order'):
+            if cst.order != order:
+                cst.order = order
+                cst.save()
+            order += 1
+
     def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta':
         if insert_after is not None:
             cstafter = Constituenta.objects.get(pk=insert_after)
@@ -510,87 +498,3 @@ class RSForm:
                 if result.contains(alias):
                     result.add_edge(id_from=alias, id_to=cst.alias)
         return result
-
-
-class PyConceptAdapter:
-    ''' RSForm adapter for interacting with pyconcept module. '''
-    def __init__(self, instance: RSForm):
-        self.schema = instance
-        self.data = self._prepare_request()
-        self._checked_data: Optional[dict] = None
-
-    def basic(self) -> dict:
-        ''' Check RSForm and return check results.
-            Warning! Does not include texts. '''
-        self._produce_response()
-        if self._checked_data is None:
-            raise ValueError('Invalid data response from pyconcept')
-        return self._checked_data
-
-    def full(self) -> dict:
-        ''' Check RSForm and return check results including initial texts. '''
-        self._produce_response()
-        if self._checked_data is None:
-            raise ValueError('Invalid data response from pyconcept')
-        return self._complete_rsform_details(self._checked_data)
-
-    def _complete_rsform_details(self, data: dict) -> dict:
-        result = deepcopy(data)
-        result['id'] = self.schema.item.pk
-        result['alias'] = self.schema.item.alias
-        result['title'] = self.schema.item.title
-        result['comment'] = self.schema.item.comment
-        result['time_update'] = self.schema.item.time_update
-        result['time_create'] = self.schema.item.time_create
-        result['is_common'] = self.schema.item.is_common
-        result['is_canonical'] = self.schema.item.is_canonical
-        result['owner'] = (self.schema.item.owner.pk if self.schema.item.owner is not None else None)
-        for cst_data in result['items']:
-            cst = Constituenta.objects.get(pk=cst_data['id'])
-            cst_data['convention'] = cst.convention
-            cst_data['term'] = {
-                'raw': cst.term_raw,
-                'resolved': cst.term_resolved,
-                'forms': cst.term_forms
-            }
-            cst_data['definition']['text'] = {
-                'raw': cst.definition_raw,
-                'resolved': cst.definition_resolved,
-            }
-        result['subscribers'] = [item.pk for item in self.schema.item.subscribers()]
-        return result
-
-    def _prepare_request(self) -> dict:
-        result: dict = {
-            'items': []
-        }
-        items = self.schema.constituents().order_by('order')
-        for cst in items:
-            result['items'].append({
-                'entityUID': cst.pk,
-                'cstType': cst.cst_type,
-                'alias': cst.alias,
-                'definition': {
-                    'formal': cst.definition_formal
-                }
-            })
-        return result
-
-    def _produce_response(self):
-        if self._checked_data is not None:
-            return
-        response = pyconcept.check_schema(json.dumps(self.data))
-        data = json.loads(response)
-        self._checked_data = {
-            'items': []
-        }
-        for cst in data['items']:
-            self._checked_data['items'].append({
-                'id': cst['entityUID'],
-                'cstType': cst['cstType'],
-                'alias': cst['alias'],
-                'definition': {
-                    'formal': cst['definition']['formal']
-                },
-                'parse': cst['parse']
-            })
diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py
index e18a9873..9c8e5ba8 100644
--- a/rsconcept/backend/apps/rsform/serializers.py
+++ b/rsconcept/backend/apps/rsform/serializers.py
@@ -1,8 +1,10 @@
 ''' Serializers for conceptual schema API. '''
+import json
 from typing import Optional, cast
 from rest_framework import serializers
 from django.db import transaction
 
+import pyconcept
 from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference
 
 from .utils import fix_old_references
@@ -31,7 +33,7 @@ class TextSerializer(serializers.Serializer):
 
 
 class LibraryItemSerializer(serializers.ModelSerializer):
-    ''' Serializer: Library item data. '''
+    ''' Serializer: LibraryItem entry. '''
     class Meta:
         ''' serializer metadata. '''
         model = LibraryItem
@@ -39,6 +41,71 @@ class LibraryItemSerializer(serializers.ModelSerializer):
         read_only_fields = ('owner', 'id', 'item_type')
 
 
+class LibraryItemDetailsSerializer(serializers.ModelSerializer):
+    ''' Serializer: LibraryItem detailed data. '''
+    class Meta:
+        ''' serializer metadata. '''
+        model = LibraryItem
+        fields = '__all__'
+        read_only_fields = ('owner', 'id', 'item_type')
+
+    def to_representation(self, instance: LibraryItem):
+        result = super().to_representation(instance)
+        result['subscribers'] = [item.pk for item in instance.subscribers()]
+        return result
+
+
+class PyConceptAdapter:
+    ''' RSForm adapter for interacting with pyconcept module. '''
+    def __init__(self, instance: RSForm):
+        self.schema = instance
+        self.data = self._prepare_request()
+        self._checked_data: Optional[dict] = None
+
+    def parse(self) -> dict:
+        ''' Check RSForm and return check results.
+            Warning! Does not include texts. '''
+        self._produce_response()
+        if self._checked_data is None:
+            raise ValueError('Invalid data response from pyconcept')
+        return self._checked_data
+
+    def _prepare_request(self) -> dict:
+        result: dict = {
+            'items': []
+        }
+        items = self.schema.constituents().order_by('order')
+        for cst in items:
+            result['items'].append({
+                'entityUID': cst.pk,
+                'cstType': cst.cst_type,
+                'alias': cst.alias,
+                'definition': {
+                    'formal': cst.definition_formal
+                }
+            })
+        return result
+
+    def _produce_response(self):
+        if self._checked_data is not None:
+            return
+        response = pyconcept.check_schema(json.dumps(self.data))
+        data = json.loads(response)
+        self._checked_data = {
+            'items': []
+        }
+        for cst in data['items']:
+            self._checked_data['items'].append({
+                'id': cst['entityUID'],
+                'cstType': cst['cstType'],
+                'alias': cst['alias'],
+                'definition': {
+                    'formal': cst['definition']['formal']
+                },
+                'parse': cst['parse']
+            })
+
+
 class RSFormSerializer(serializers.ModelSerializer):
     ''' Serializer: Detailed data for RSForm. '''
     class Meta:
@@ -46,13 +113,30 @@ class RSFormSerializer(serializers.ModelSerializer):
         model = RSForm
 
     def to_representation(self, instance: RSForm):
-        result = LibraryItemSerializer(instance.item).data
+        result = LibraryItemDetailsSerializer(instance.item).data
         result['items'] = []
         for cst in instance.constituents().order_by('order'):
             result['items'].append(ConstituentaSerializer(cst).data)
         return result
 
 
+class RSFormParseSerializer(serializers.ModelSerializer):
+    ''' Serializer: Detailed data for RSForm including parse. '''
+    class Meta:
+        ''' serializer metadata. '''
+        model = RSForm
+
+    def to_representation(self, instance: RSForm):
+        result = RSFormSerializer(instance).data
+        parse = PyConceptAdapter(instance).parse()
+        for cst_data in result['items']:
+            cst_data['parse'] = next(
+                cst['parse'] for cst in parse['items']
+                if cst['id'] == cst_data['id']
+            )
+        return result
+
+
 class RSFormUploadSerializer(serializers.Serializer):
     ''' Upload data for RSForm serializer. '''
     file = serializers.FileField()
@@ -193,7 +277,6 @@ class RSFormTRSSerializer(serializers.Serializer):
             if prev_cst.pk not in loaded_ids:
                 prev_cst.delete()
 
-        instance.update_order()
         instance.resolve_all_text()
         instance.item.save()
         return instance
diff --git a/rsconcept/backend/apps/rsform/tests/t_models.py b/rsconcept/backend/apps/rsform/tests/t_models.py
index 41970602..0f3575f1 100644
--- a/rsconcept/backend/apps/rsform/tests/t_models.py
+++ b/rsconcept/backend/apps/rsform/tests/t_models.py
@@ -200,10 +200,10 @@ class TestRSForm(TestCase):
         d2 = schema.insert_at(1, 'D2', CstType.TERM)
         d1.refresh_from_db()
         self.assertEqual(d1.order, 3)
-        self.assertEqual(d2.order, 2)
+        self.assertEqual(d2.order, 1)
 
         x2 = schema.insert_at(4, 'X2', CstType.BASE)
-        self.assertEqual(x2.order, 2)
+        self.assertEqual(x2.order, 4)
 
     def test_insert_last(self):
         schema = RSForm.create(title='Test')
@@ -259,10 +259,10 @@ class TestRSForm(TestCase):
         x2.refresh_from_db()
         d1.refresh_from_db()
         d2.refresh_from_db()
-        self.assertEqual(x1.order, 2)
+        self.assertEqual(x1.order, 3)
         self.assertEqual(x2.order, 1)
         self.assertEqual(d1.order, 4)
-        self.assertEqual(d2.order, 3)
+        self.assertEqual(d2.order, 2)
 
     def test_move_cst_down(self):
         schema = RSForm.create(title='Test')
diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py
index b3d290f3..d15e8cb4 100644
--- a/rsconcept/backend/apps/rsform/tests/t_views.py
+++ b/rsconcept/backend/apps/rsform/tests/t_views.py
@@ -213,10 +213,14 @@ class TestLibraryViewset(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertFalse(_response_contains(response, self.unowned))
 
+        user2 =  User.objects.create(username='UserTest2')
         Subscription.subscribe(user=self.user, item=self.unowned)
+        Subscription.subscribe(user=user2, item=self.unowned)
+        Subscription.subscribe(user=user2, item=self.owned)
         response = self.client.get('/api/library/active')
         self.assertEqual(response.status_code, 200)
         self.assertTrue(_response_contains(response, self.unowned))
+        self.assertEqual(len(response.data), 3)
 
     def test_subscriptions(self):
         response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe')
@@ -287,11 +291,11 @@ class TestRSFormViewset(APITestCase):
         self.assertEqual(len(response.data['items']), 2)
         self.assertEqual(response.data['items'][0]['id'], x1.id)
         self.assertEqual(response.data['items'][0]['parse']['status'], 'verified')
-        self.assertEqual(response.data['items'][0]['term']['raw'], x1.term_raw)
-        self.assertEqual(response.data['items'][0]['term']['resolved'], x1.term_resolved)
+        self.assertEqual(response.data['items'][0]['term_raw'], x1.term_raw)
+        self.assertEqual(response.data['items'][0]['term_resolved'], x1.term_resolved)
         self.assertEqual(response.data['items'][1]['id'], x2.id)
-        self.assertEqual(response.data['items'][1]['term']['raw'], x2.term_raw)
-        self.assertEqual(response.data['items'][1]['term']['resolved'], x2.term_resolved)
+        self.assertEqual(response.data['items'][1]['term_raw'], x2.term_raw)
+        self.assertEqual(response.data['items'][1]['term_resolved'], x2.term_resolved)
         self.assertEqual(response.data['subscribers'], [self.user.pk])
 
     def test_check(self):
@@ -412,6 +416,7 @@ class TestRSFormViewset(APITestCase):
         d1.definition_formal = 'X1'
         d1.save()
         
+        self.assertEqual(d1.order, 4)
         self.assertEqual(self.cst1.order, 1)
         self.assertEqual(self.cst1.alias, 'X1')
         self.assertEqual(self.cst1.cst_type, CstType.BASE)
@@ -422,9 +427,10 @@ class TestRSFormViewset(APITestCase):
         self.assertEqual(response.data['new_cst']['cst_type'], 'term')
         d1.refresh_from_db()
         self.cst1.refresh_from_db()
+        self.assertEqual(d1.order, 4)
         self.assertEqual(d1.term_resolved, '')
         self.assertEqual(d1.term_raw,  '@{D2|plur}')
-        self.assertEqual(self.cst1.order, 2)
+        self.assertEqual(self.cst1.order, 1)
         self.assertEqual(self.cst1.alias, 'D2')
         self.assertEqual(self.cst1.cst_type, CstType.TERM)
 
@@ -560,10 +566,10 @@ class TestRSFormViewset(APITestCase):
         self.assertEqual(response.status_code, 201)
         self.assertEqual(response.data['title'], 'Title')
         self.assertEqual(response.data['items'][0]['alias'], x1.alias)
-        self.assertEqual(response.data['items'][0]['term']['raw'], x1.term_raw)
-        self.assertEqual(response.data['items'][0]['term']['resolved'], x1.term_resolved)
-        self.assertEqual(response.data['items'][1]['term']['raw'], d1.term_raw)
-        self.assertEqual(response.data['items'][1]['term']['resolved'], d1.term_resolved)
+        self.assertEqual(response.data['items'][0]['term_raw'], x1.term_raw)
+        self.assertEqual(response.data['items'][0]['term_resolved'], x1.term_resolved)
+        self.assertEqual(response.data['items'][1]['term_raw'], d1.term_raw)
+        self.assertEqual(response.data['items'][1]['term_resolved'], d1.term_resolved)
 
 
 class TestFunctionalViews(APITestCase):
diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py
index 74ad2239..d38c9bbd 100644
--- a/rsconcept/backend/apps/rsform/views.py
+++ b/rsconcept/backend/apps/rsform/views.py
@@ -25,7 +25,9 @@ class LibraryActiveView(generics.ListAPIView):
         user = self.request.user
         if not user.is_anonymous:
             # pylint: disable=unsupported-binary-operation
-            return m.LibraryItem.objects.filter(Q(is_common=True) | Q(owner=user) | Q(subscription__user=user))
+            return m.LibraryItem.objects.filter(
+                Q(is_common=True) | Q(owner=user) | Q(subscription__user=user)
+            ).distinct()
         else:
             return m.LibraryItem.objects.filter(is_common=True)
 
@@ -94,7 +96,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
             clone = s.RSFormTRSSerializer(data=clone_data, context={'load_meta': True})
             clone.is_valid(raise_exception=True)
             new_schema = clone.save()
-            return Response(status=201, data=m.PyConceptAdapter(new_schema).full())
+            return Response(status=201, data=s.RSFormParseSerializer(new_schema).data)
         return Response(status=404)
 
     @transaction.atomic
@@ -153,7 +155,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
         schema.item.refresh_from_db()
         response = Response(status=201, data={
             'new_cst': s.ConstituentaSerializer(new_cst).data,
-            'schema': m.PyConceptAdapter(schema).full()
+            'schema': s.RSFormParseSerializer(schema).data
         })
         response['Location'] = new_cst.get_absolute_url()
         return response
@@ -169,12 +171,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
         serializer.save()
         mapping = { old_alias: serializer.validated_data['alias'] }
         schema.apply_mapping(mapping, change_aliases=False)
-        schema.update_order()
         schema.item.refresh_from_db()
         cst = m.Constituenta.objects.get(pk=serializer.validated_data['id'])
         return Response(status=200, data={
             'new_cst': s.ConstituentaSerializer(cst).data,
-            'schema': m.PyConceptAdapter(schema).full()
+            'schema': s.RSFormParseSerializer(schema).data
         })
 
     @action(detail=True, methods=['patch'], url_path='cst-multidelete')
@@ -185,7 +186,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
         serializer.is_valid(raise_exception=True)
         schema.delete_cst(serializer.validated_data['constituents'])
         schema.item.refresh_from_db()
-        return Response(status=202, data=m.PyConceptAdapter(schema).full())
+        return Response(status=202, data=s.RSFormParseSerializer(schema).data)
 
     @action(detail=True, methods=['patch'], url_path='cst-moveto')
     def cst_moveto(self, request, pk):
@@ -195,14 +196,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
         serializer.is_valid(raise_exception=True)
         schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
         schema.item.refresh_from_db()
-        return Response(status=200, data=m.PyConceptAdapter(schema).full())
+        return Response(status=200, data=s.RSFormParseSerializer(schema).data)
 
     @action(detail=True, methods=['patch'], url_path='reset-aliases')
     def reset_aliases(self, request, pk):
         ''' Endpoint: Recreate all aliases based on order. '''
         schema = self._get_schema()
         schema.reset_aliases()
-        return Response(status=200, data=m.PyConceptAdapter(schema).full())
+        return Response(status=200, data=s.RSFormParseSerializer(schema).data)
 
     @action(detail=True, methods=['patch'], url_path='load-trs')
     def load_trs(self, request, pk):
@@ -217,7 +218,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
         serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
         serializer.is_valid(raise_exception=True)
         schema = serializer.save()
-        return Response(status=200, data=m.PyConceptAdapter(schema).full())
+        return Response(status=200, data=s.RSFormParseSerializer(schema).data)
 
     @action(detail=True, methods=['get'])
     def contents(self, request, pk):
@@ -229,27 +230,26 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
     def details(self, request, pk):
         ''' Endpoint: Detailed schema view including statuses and parse. '''
         schema = self._get_schema()
-        serializer = m.PyConceptAdapter(schema)
-        return Response(serializer.full())
+        serializer = s.RSFormParseSerializer(schema)
+        return Response(serializer.data)
 
     @action(detail=True, methods=['post'])
     def check(self, request, pk):
         ''' Endpoint: Check RSLang expression against schema context. '''
-        schema =  m.PyConceptAdapter(self._get_schema())
         serializer = s.ExpressionSerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         expression = serializer.validated_data['expression']
+        schema =  s.PyConceptAdapter(self._get_schema())
         result = pyconcept.check_expression(json.dumps(schema.data), expression)
         return Response(json.loads(result))
 
     @action(detail=True, methods=['post'])
     def resolve(self, request, pk):
         ''' Endpoint: Resolve refenrces in text against schema terms context. '''
-        schema = self._get_schema()
         serializer = s.TextSerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         text = serializer.validated_data['text']
-        resolver = schema.resolver()
+        resolver = self._get_schema().resolver()
         resolver.resolve(text)
         return Response(status=200, data=s.ResolverSerializer(resolver).data)
 
diff --git a/rsconcept/frontend/.dockerignore b/rsconcept/frontend/.dockerignore
index cd7ad448..409a5cb9 100644
--- a/rsconcept/frontend/.dockerignore
+++ b/rsconcept/frontend/.dockerignore
@@ -1,3 +1,4 @@
 # Dev specific
 .gitignore
-node_modules
\ No newline at end of file
+node_modules
+.env.local
\ No newline at end of file
diff --git a/rsconcept/frontend/.env.local b/rsconcept/frontend/.env.local
new file mode 100644
index 00000000..e406b9c7
--- /dev/null
+++ b/rsconcept/frontend/.env.local
@@ -0,0 +1,5 @@
+# Local build config
+
+VITE_PORTAL_BACKEND=http://localhost:8000
+VITE_PORTAL_FRONT_PORT=3000
+VITE_PORTAL_FRONT_HTTPS=false
diff --git a/rsconcept/frontend/Dockerfile b/rsconcept/frontend/Dockerfile
index d7a259f6..b8f293da 100644
--- a/rsconcept/frontend/Dockerfile
+++ b/rsconcept/frontend/Dockerfile
@@ -5,11 +5,14 @@ RUN apt-get update -qq && \
     rm -rf /var/lib/apt/lists/*
 
 # ======= Build =======
+ARG BUILD_TYPE=production
 FROM node-base as builder
 
 WORKDIR /result
 
 COPY ./ ./
+COPY ./env/.env.$BUILD_TYPE ./
+RUN rm -rf ./env
 RUN npm install
 ENV NODE_ENV production
 RUN npm run build
diff --git a/rsconcept/frontend/Dockerfile.dev b/rsconcept/frontend/Dockerfile.dev
new file mode 100644
index 00000000..be4e8c9f
--- /dev/null
+++ b/rsconcept/frontend/Dockerfile.dev
@@ -0,0 +1,17 @@
+# ======== Multi-stage base ==========
+FROM node:bullseye-slim as node-base
+RUN apt-get update -qq && \
+    apt-get upgrade -y && \
+    rm -rf /var/lib/apt/lists/*
+
+# ========= Server =======
+FROM node-base as product-server
+ARG BUILD_TYPE=production
+
+WORKDIR /home
+
+COPY ./ ./
+COPY ./env/.env.$BUILD_TYPE ./
+RUN rm -rf ./env
+
+RUN npm install
diff --git a/rsconcept/frontend/env/.env.development b/rsconcept/frontend/env/.env.development
new file mode 100644
index 00000000..b662d21b
--- /dev/null
+++ b/rsconcept/frontend/env/.env.development
@@ -0,0 +1,5 @@
+# Frontend public settings: Production Local
+
+VITE_PORTAL_BACKEND=http://localhost:8002
+VITE_PORTAL_FRONT_PORT=3002
+VITE_PORTAL_FRONT_HTTPS=false
diff --git a/rsconcept/frontend/env/.env.production b/rsconcept/frontend/env/.env.production
new file mode 100644
index 00000000..82fc5eb2
--- /dev/null
+++ b/rsconcept/frontend/env/.env.production
@@ -0,0 +1,5 @@
+# Frontend public settings: Production
+
+VITE_PORTAL_BACKEND=https://portal.acconcept.ru:8082
+VITE_PORTAL_FRONT_PORT=3000
+VITE_PORTAL_FRONT_HTTPS=true
diff --git a/rsconcept/frontend/env/.env.production.local b/rsconcept/frontend/env/.env.production.local
new file mode 100644
index 00000000..afc1a660
--- /dev/null
+++ b/rsconcept/frontend/env/.env.production.local
@@ -0,0 +1,6 @@
+# Frontend public settings: Production Local
+
+VITE_PORTAL_BACKEND=https://localhost:8001
+VITE_PORTAL_FRONT_PORT=3001
+VITE_PORTAL_FRONT_HTTPS=true
+
diff --git a/rsconcept/frontend/src/components/Common/Checkbox.tsx b/rsconcept/frontend/src/components/Common/Checkbox.tsx
index c29fdef7..25a1e709 100644
--- a/rsconcept/frontend/src/components/Common/Checkbox.tsx
+++ b/rsconcept/frontend/src/components/Common/Checkbox.tsx
@@ -18,7 +18,7 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
 
   const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer';
   
-  function handleLabelClick(event: React.MouseEvent): void {
+  function handleClick(event: React.MouseEvent): void {
     event.preventDefault();
     if (!disabled) {
       inputRef.current?.click();
@@ -26,7 +26,12 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
   } 
 
   return (
-    
+
+ ); } diff --git a/rsconcept/frontend/src/components/Common/Dropdown.tsx b/rsconcept/frontend/src/components/Common/Dropdown.tsx index af7b538c..1af03a68 100644 --- a/rsconcept/frontend/src/components/Common/Dropdown.tsx +++ b/rsconcept/frontend/src/components/Common/Dropdown.tsx @@ -7,7 +7,7 @@ interface DropdownProps { function Dropdown({ children, widthClass = 'w-fit', stretchLeft }: DropdownProps) { return (
-
+
{children}
diff --git a/rsconcept/frontend/src/components/Common/DropdownButton.tsx b/rsconcept/frontend/src/components/Common/DropdownButton.tsx index d3ac47de..16d77e8c 100644 --- a/rsconcept/frontend/src/components/Common/DropdownButton.tsx +++ b/rsconcept/frontend/src/components/Common/DropdownButton.tsx @@ -1,16 +1,16 @@ -interface NavigationTextItemProps { - description?: string | undefined +interface DropdownButtonProps { + tooltip?: string | undefined onClick?: () => void disabled?: boolean children: React.ReactNode } -function DropdownButton({ description = '', onClick, disabled, children }: NavigationTextItemProps) { - const behavior = (onClick ? 'cursor-pointer clr-hover' : 'cursor-default') + ' disabled:cursor-not-allowed'; +function DropdownButton({ tooltip, onClick, disabled, children }: DropdownButtonProps) { + const behavior = (onClick ? 'cursor-pointer disabled:cursor-not-allowed clr-hover' : 'cursor-default'); return (
{ error && } diff --git a/rsconcept/frontend/src/pages/HomePage.tsx b/rsconcept/frontend/src/pages/HomePage.tsx index 1184cdca..40eca170 100644 --- a/rsconcept/frontend/src/pages/HomePage.tsx +++ b/rsconcept/frontend/src/pages/HomePage.tsx @@ -13,7 +13,7 @@ function HomePage() { setTimeout(() => { navigate('/manuals'); }, TIMEOUT_UI_REFRESH); - } else if(!user.is_staff) { + } else { setTimeout(() => { navigate('/library'); }, TIMEOUT_UI_REFRESH); diff --git a/rsconcept/frontend/src/pages/LibraryPage/PickerStrategy.tsx b/rsconcept/frontend/src/pages/LibraryPage/PickerStrategy.tsx index f0de720d..4236b2e1 100644 --- a/rsconcept/frontend/src/pages/LibraryPage/PickerStrategy.tsx +++ b/rsconcept/frontend/src/pages/LibraryPage/PickerStrategy.tsx @@ -1,10 +1,10 @@ import { useCallback } from 'react'; import Button from '../../components/Common/Button'; -import Checkbox from '../../components/Common/Checkbox'; import Dropdown from '../../components/Common/Dropdown'; -import DropdownButton from '../../components/Common/DropdownButton'; +import DropdownCheckbox from '../../components/Common/DropdownCheckbox'; import { FilterCogIcon } from '../../components/Icons'; +import { useAuth } from '../../context/AuthContext'; import useDropdown from '../../hooks/useDropdown'; import { LibraryFilterStrategy } from '../../utils/models'; @@ -15,6 +15,7 @@ interface PickerStrategyProps { function PickerStrategy({ value, onChange }: PickerStrategyProps) { const pickerMenu = useDropdown(); + const { user } = useAuth(); const handleChange = useCallback( (newValue: LibraryFilterStrategy) => { @@ -34,53 +35,44 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) { /> { pickerMenu.isActive && - handleChange(LibraryFilterStrategy.MANUAL)}> - - - handleChange(LibraryFilterStrategy.COMMON)}> - - - handleChange(LibraryFilterStrategy.CANONICAL)}> - - - handleChange(LibraryFilterStrategy.PERSONAL)}> - - - handleChange(LibraryFilterStrategy.SUBSCRIBE)}> - - - handleChange(LibraryFilterStrategy.OWNED)}> - - + handleChange(LibraryFilterStrategy.MANUAL)} + value={value === LibraryFilterStrategy.MANUAL} + label='Отображать все' + /> + handleChange(LibraryFilterStrategy.COMMON)} + value={value === LibraryFilterStrategy.COMMON} + label='Общедоступные' + tooltip='Отображать только общедоступные схемы' + /> + handleChange(LibraryFilterStrategy.CANONICAL)} + value={value === LibraryFilterStrategy.CANONICAL} + label='Неизменные' + tooltip='Отображать только неизменные схемы' + /> + handleChange(LibraryFilterStrategy.PERSONAL)} + value={value === LibraryFilterStrategy.PERSONAL} + label='Личные' + disabled={!user} + tooltip='Отображать только подписки и владеемые схемы' + /> + handleChange(LibraryFilterStrategy.SUBSCRIBE)} + value={value === LibraryFilterStrategy.SUBSCRIBE} + label='Подписки' + disabled={!user} + tooltip='Отображать только подписки' + /> + handleChange(LibraryFilterStrategy.OWNED)} + value={value === LibraryFilterStrategy.OWNED} + disabled={!user} + label='Я - Владелец!' + tooltip='Отображать только владеемые схемы' + /> } ); diff --git a/rsconcept/frontend/src/pages/LibraryPage/SearchPanel.tsx b/rsconcept/frontend/src/pages/LibraryPage/SearchPanel.tsx index c6b12108..23815f43 100644 --- a/rsconcept/frontend/src/pages/LibraryPage/SearchPanel.tsx +++ b/rsconcept/frontend/src/pages/LibraryPage/SearchPanel.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { MagnifyingGlassIcon } from '../../components/Icons'; import { useAuth } from '../../context/AuthContext'; +import useLocalStorage from '../../hooks/useLocalStorage'; import { ILibraryFilter, LibraryFilterStrategy } from '../../utils/models'; import PickerStrategy from './PickerStrategy'; @@ -30,7 +31,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) { const { user } = useAuth(); const [query, setQuery] = useState(''); - const [strategy, setStrategy] = useState(LibraryFilterStrategy.MANUAL); + const [strategy, setStrategy] = useLocalStorage('search_strategy', LibraryFilterStrategy.MANUAL); function handleChangeQuery(event: React.ChangeEvent) { const newQuery = event.target.value; @@ -49,11 +50,15 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) { useLayoutEffect(() => { const searchFilter = new URLSearchParams(search).get('filter') as LibraryFilterStrategy | null; + if (searchFilter === null) { + navigate(`/library?filter=${strategy}`); + return; + } const inputStrategy = searchFilter && Object.values(LibraryFilterStrategy).includes(searchFilter) ? searchFilter : LibraryFilterStrategy.MANUAL; setQuery('') setStrategy(inputStrategy) setFilter(ApplyStrategy(inputStrategy)); - }, [user, search, setQuery, setFilter]); + }, [user, search, setQuery, setFilter, setStrategy, strategy, navigate]); const handleChangeStrategy = useCallback( (value: LibraryFilterStrategy) => { @@ -64,7 +69,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) { }, [strategy, navigate]); return ( -
+
Фильтр diff --git a/rsconcept/frontend/src/pages/LoginPage.tsx b/rsconcept/frontend/src/pages/LoginPage.tsx index 303f62ad..77180be8 100644 --- a/rsconcept/frontend/src/pages/LoginPage.tsx +++ b/rsconcept/frontend/src/pages/LoginPage.tsx @@ -10,6 +10,7 @@ import { useAuth } from '../context/AuthContext'; import { IUserLoginData } from '../utils/models'; function LoginPage() { + const location = useLocation(); const navigate = useNavigate(); const search = useLocation().search; const { user, login, loading, error, setError } = useAuth(); @@ -34,7 +35,13 @@ function LoginPage() { username: username, password: password }; - login(data, () => navigate('/library')); + login(data, () => { + if (location.key !== "default") { + navigate(-1); + } else { + navigate('/library'); + } + }); } } @@ -44,7 +51,7 @@ function LoginPage() { ? {`Вы вошли в систему как ${user.username}`} :
@@ -64,10 +71,10 @@ function LoginPage() { onChange={event => setPassword(event.target.value)} /> -
+
diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta.tsx index f832ec7b..53a31557 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useMemo, useState } from 'react'; +import { Dispatch, SetStateAction, useLayoutEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import ConceptTooltip from '../../components/Common/ConceptTooltip'; @@ -9,7 +9,6 @@ import TextArea from '../../components/Common/TextArea'; import HelpConstituenta from '../../components/Help/HelpConstituenta'; import { DumpBinIcon, HelpIcon, PenIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons'; import { useRSForm } from '../../context/RSFormContext'; -import useModificationPrompt from '../../hooks/useModificationPrompt'; import { CstType, EditMode, ICstCreateData, ICstRenameData, ICstUpdateData, SyntaxTree } from '../../utils/models'; import { getCstTypificationLabel } from '../../utils/staticUI'; import EditorRSExpression from './EditorRSExpression'; @@ -25,16 +24,19 @@ interface EditorConstituentaProps { onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void onRenameCst: (initial: ICstRenameData) => void onDeleteCst: (selected: number[], callback?: (items: number[]) => void) => void + isModified: boolean + setIsModified: Dispatch> } -function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onOpenEdit, onDeleteCst }: EditorConstituentaProps) { +function EditorConstituenta({ + isModified, setIsModified, activeID, + onShowAST, onCreateCst, onRenameCst, onOpenEdit, onDeleteCst +}: EditorConstituentaProps) { const { schema, processing, isEditable, cstUpdate } = useRSForm(); const activeCst = useMemo( () => { return schema?.items?.find((cst) => cst.id === activeID); }, [schema?.items, activeID]); - - const { isModified, setIsModified } = useModificationPrompt(); const [editMode, setEditMode] = useState(EditMode.TEXT); @@ -54,23 +56,24 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO return; } setIsModified( - activeCst.term.raw !== term || - activeCst.definition.text.raw !== textDefinition || + activeCst.term_raw !== term || + activeCst.definition_raw !== textDefinition || activeCst.convention !== convention || - activeCst.definition.formal !== expression + activeCst.definition_formal !== expression ); - }, [activeCst, activeCst?.term, activeCst?.definition.formal, - activeCst?.definition.text.raw, activeCst?.convention, + return () => setIsModified(false); + }, [activeCst, activeCst?.term_raw, activeCst?.definition_formal, + activeCst?.definition_raw, activeCst?.convention, term, textDefinition, expression, convention, setIsModified]); useLayoutEffect( () => { if (activeCst) { setAlias(activeCst.alias); - setConvention(activeCst.convention ?? ''); - setTerm(activeCst.term?.raw ?? ''); - setTextDefinition(activeCst.definition?.text?.raw ?? ''); - setExpression(activeCst.definition?.formal ?? ''); + setConvention(activeCst.convention || ''); + setTerm(activeCst.term_raw || ''); + setTextDefinition(activeCst.definition_raw || ''); + setExpression(activeCst.definition_formal || ''); setTypification(activeCst ? getCstTypificationLabel(activeCst) : 'N/A'); } }, [activeCst, onOpenEdit, schema]); @@ -106,7 +109,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO } const data: ICstCreateData = { insert_after: activeID, - cst_type: activeCst?.cstType ?? CstType.BASE, + cst_type: activeCst?.cst_type ?? CstType.BASE, alias: '', term_raw: '', definition_formal: '', @@ -123,7 +126,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO const data: ICstRenameData = { id: activeID, alias: activeCst?.alias, - cst_type: activeCst.cstType + cst_type: activeCst.cst_type }; onRenameCst(data); } @@ -181,8 +184,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение' rows={2} value={term} - initialValue={activeCst?.term.raw ?? ''} - resolved={activeCst?.term.resolved ?? ''} + initialValue={activeCst?.term_raw ?? ''} + resolved={activeCst?.term_resolved ?? ''} disabled={!isEnabled} spellCheck onChange={event => setTerm(event.target.value)} @@ -209,8 +212,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO placeholder='Лингвистическая интерпретация формального выражения' rows={4} value={textDefinition} - initialValue={activeCst?.definition.text.raw ?? ''} - resolved={activeCst?.definition.text.resolved ?? ''} + initialValue={activeCst?.definition_raw ?? ''} + resolved={activeCst?.definition_resolved ?? ''} disabled={!isEnabled} spellCheck onChange={event => setTextDefinition(event.target.value)} diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorItems.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorItems.tsx index 4c9732e1..a9863f2e 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorItems.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorItems.tsx @@ -215,7 +215,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps) { name: 'Термин', id: 'term', - selector: (cst: IConstituenta) => cst.term?.resolved ?? cst.term?.raw ?? '', + selector: (cst: IConstituenta) => cst.term_resolved || cst.term_raw || '', width: '350px', minWidth: '150px', maxWidth: '350px', @@ -225,7 +225,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps) { name: 'Формальное определение', id: 'expression', - selector: (cst: IConstituenta) => cst.definition?.formal ?? '', + selector: (cst: IConstituenta) => cst.definition_formal || '', minWidth: '300px', maxWidth: '500px', grow: 2, @@ -237,7 +237,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps) id: 'definition', cell: (cst: IConstituenta) => (
- {cst.definition?.text.resolved ?? cst.definition?.text.raw ?? ''} + {cst.definition_resolved || cst.definition_raw || ''}
), minWidth: '200px', diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSExpression.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSExpression.tsx index 9d7cb956..dc299e48 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSExpression.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSExpression.tsx @@ -220,11 +220,17 @@ function EditorRSExpression({
{ loading && } { !loading && parseData && - onShowAST(value, ast)} onShowError={onShowError} />} + { !loading && !parseData && + }
}
); diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm.tsx index 87a536b8..682a1255 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useLayoutEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { toast } from 'react-toastify'; @@ -13,7 +13,6 @@ import { CrownIcon, DownloadIcon, DumpBinIcon, HelpIcon, SaveIcon, ShareIcon } f import { useAuth } from '../../context/AuthContext'; import { useRSForm } from '../../context/RSFormContext'; import { useUsers } from '../../context/UsersContext'; -import useModificationPrompt from '../../hooks/useModificationPrompt'; import { IRSFormCreateData, LibraryItemType } from '../../utils/models'; interface EditorRSFormProps { @@ -21,9 +20,11 @@ interface EditorRSFormProps { onClaim: () => void onShare: () => void onDownload: () => void + isModified: boolean + setIsModified: Dispatch> } -function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormProps) { +function EditorRSForm({ onDestroy, onClaim, onShare, isModified, setIsModified, onDownload }: EditorRSFormProps) { const intl = useIntl(); const { getUserLabel } = useUsers(); const { @@ -38,8 +39,6 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP const [common, setCommon] = useState(false); const [canonical, setCanonical] = useState(false); - const { isModified, setIsModified } = useModificationPrompt(); - useLayoutEffect(() => { if (!schema) { setIsModified(false); @@ -52,6 +51,7 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP schema.is_common !== common || schema.is_canonical !== canonical ); + return () => setIsModified(false); }, [schema, schema?.title, schema?.alias, schema?.comment, schema?.is_common, schema?.is_canonical, title, alias, comment, common, canonical, setIsModified]); @@ -138,10 +138,10 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP disabled={!isEditable} onChange={event => setCommon(event.target.checked)} /> - setCanonical(event.target.checked)} /> diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx index faa5784c..46d908aa 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx @@ -31,7 +31,7 @@ const TREE_SIZE_MILESTONE = 50; function getCstNodeColor(cst: IConstituenta, coloringScheme: ColoringScheme, colors: IColorTheme): string { if (coloringScheme === 'type') { - return getCstClassColor(cst.cstClass, colors); + return getCstClassColor(cst.cst_class, colors); } if (coloringScheme === 'status') { return getCstStatusColor(cst.status, colors); @@ -125,14 +125,14 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra } if (noTemplates) { schema.items.forEach(cst => { - if (cst.isTemplate) { + if (cst.is_template) { graph.foldNode(cst.id); } }); } if (allowedTypes.length < Object.values(CstType).length) { schema.items.forEach(cst => { - if (!allowedTypes.includes(cst.cstType)) { + if (!allowedTypes.includes(cst.cst_type)) { graph.foldNode(cst.id); } }); @@ -173,7 +173,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra result.push({ id: String(node.id), fill: getCstNodeColor(cst, coloringScheme, colors), - label: cst.term.resolved && !noTerms ? `${cst.alias}: ${cst.term.resolved}` : cst.alias + label: cst.term_resolved && !noTerms ? `${cst.alias}: ${cst.term_resolved}` : cst.alias }); } }); @@ -338,8 +338,8 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra const canvasHeight = useMemo( () => { return !noNavigation ? - 'calc(100vh - 10.1rem)' - : 'calc(100vh - 2.1rem)'; + 'calc(100vh - 9.8rem - 4px)' + : 'calc(100vh - 3rem - 4px)'; }, [noNavigation]); const dismissedStyle = useCallback( @@ -358,9 +358,9 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
{hoverCst &&
-
} @@ -460,7 +460,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
-
+
(RSTabID.CARD); const [activeID, setActiveID] = useState(undefined); @@ -204,7 +207,7 @@ function RSTabs() { } destroySchema(schema.id, () => { toast.success('Схема удалена'); - navigate(`/library?filter=${LibraryFilterStrategy.PERSONAL}`); + navigate('/library'); }); }, [schema, destroySchema, navigate]); @@ -226,6 +229,11 @@ function RSTabs() { const onDownloadSchema = useCallback( () => { + if (isModified) { + if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) { + return; + } + } const fileName = (schema?.alias ?? 'Schema') + '.trs'; download( (data) => { @@ -235,8 +243,18 @@ function RSTabs() { console.error(error); } }); - }, [schema?.alias, download]); - + }, [schema?.alias, download, isModified]); + + const handleShowClone = useCallback( + () => { + if (isModified) { + if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) { + return; + } + } + setShowClone(true); + }, [isModified]); + const handleToggleSubscribe = useCallback( () => { if (isTracking) { @@ -302,7 +320,7 @@ function RSTabs() { onClaim={onClaimSchema} onShare={onShareSchema} onToggleSubscribe={handleToggleSubscribe} - showCloneDialog={() => setShowClone(true)} + showCloneDialog={handleShowClone} showUploadDialog={() => setShowUpload(true)} /> Паспорт схемы @@ -315,7 +333,9 @@ function RSTabs() { - -
@@ -142,17 +142,18 @@ function RSTabsMenu({
{(isOwned || user?.is_staff) && - - - } + } {user?.is_staff && - - - } + } }
diff --git a/rsconcept/frontend/src/pages/RSFormPage/elements/ViewSideConstituents.tsx b/rsconcept/frontend/src/pages/RSFormPage/elements/ViewSideConstituents.tsx index 372552ce..24483384 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/elements/ViewSideConstituents.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/elements/ViewSideConstituents.tsx @@ -49,7 +49,16 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }: const diff = Array.from(aliases).filter(name => !names.includes(name)); if (diff.length > 0) { diff.forEach( - (alias, index) => filtered.push(getMockConstituenta(-index, alias, CstType.BASE, 'Конституента отсутствует'))); + (alias, index) => filtered.push( + getMockConstituenta( + schema.id, + -index, + alias, + CstType.BASE, + 'Конституента отсутствует' + ) + ) + ); } } else if (!activeID) { filtered = schema.items @@ -133,7 +142,7 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }: { name: 'Выражение', id: 'expression', - selector: (cst: IConstituenta) => cst.definition?.formal ?? '', + selector: (cst: IConstituenta) => cst.definition_formal || '', minWidth: '200px', hide: 1600, grow: 2, diff --git a/rsconcept/frontend/src/pages/RegisterPage.tsx b/rsconcept/frontend/src/pages/RegisterPage.tsx index d5133fab..9345871a 100644 --- a/rsconcept/frontend/src/pages/RegisterPage.tsx +++ b/rsconcept/frontend/src/pages/RegisterPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; import BackendError from '../components/BackendError'; +import Button from '../components/Common/Button'; import Form from '../components/Common/Form'; import SubmitButton from '../components/Common/SubmitButton'; import TextInput from '../components/Common/TextInput'; @@ -10,6 +11,7 @@ import { useAuth } from '../context/AuthContext'; import { type IUserSignupData } from '../utils/models'; function RegisterPage() { + const location = useLocation(); const navigate = useNavigate(); const { user, signup, loading, error, setError } = useAuth(); @@ -24,6 +26,14 @@ function RegisterPage() { setError(undefined); }, [username, email, password, password2, setError]); + function handleCancel() { + if (location.key !== "default") { + navigate(-1); + } else { + navigate('/library'); + } + } + function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (!loading) { @@ -48,7 +58,7 @@ function RegisterPage() { {`Вы вошли в систему как ${user.username}. Если хотите зарегистрировать нового пользователя, выйдите из системы (меню в правом верхнем углу экрана)`}} { !user && @@ -89,8 +99,17 @@ function RegisterPage() { onChange={event => setLastName(event.target.value)} /> -
- +
+ +
{ error && } diff --git a/rsconcept/frontend/src/utils/constants.ts b/rsconcept/frontend/src/utils/constants.ts index 226cf559..15e2c5da 100644 --- a/rsconcept/frontend/src/utils/constants.ts +++ b/rsconcept/frontend/src/utils/constants.ts @@ -1,16 +1,7 @@ // Constants -const prod = { - backend: 'https://portal.acconcept.ru:8082', - // backend: 'https://dev.concept.ru:8000', - // backend: 'https://localhost:8000', - // backend: 'https://api.portal.concept.ru', +export const config = { + backend: import.meta.env.VITE_PORTAL_BACKEND as string }; - -const dev = { - backend: 'http://localhost:8000', -}; - -export const config = process.env.NODE_ENV === 'production' ? prod : dev; export const TIMEOUT_UI_REFRESH = 100; export const youtube = { diff --git a/rsconcept/frontend/src/utils/models.ts b/rsconcept/frontend/src/utils/models.ts index eabd3df4..332cef82 100644 --- a/rsconcept/frontend/src/utils/models.ts +++ b/rsconcept/frontend/src/utils/models.ts @@ -148,33 +148,9 @@ export enum CstClass { TEMPLATE = 'template' } -export interface IConstituenta { - id: number - alias: string - cstType: CstType - convention: string - term: { - raw: string - resolved: string - forms: string[] - } - definition: { - formal: string - text: { - raw: string - resolved: string - } - } - cstClass: CstClass - status: ExpressionStatus - isTemplate: boolean - parse: { - status: ParsingStatus - valueClass: ValueClass - typification: string - syntaxTree: string - args: IFunctionArg[] - } +export interface TermForm { + text: string + tags: string } export interface IConstituentaMeta { @@ -189,6 +165,21 @@ export interface IConstituentaMeta { definition_resolved: string term_raw: string term_resolved: string + term_forms: TermForm[] +} + +export interface IConstituenta +extends IConstituentaMeta { + cst_class: CstClass + status: ExpressionStatus + is_template: boolean + parse: { + status: ParsingStatus + valueClass: ValueClass + typification: string + syntaxTree: string + args: IFunctionArg[] + } } export interface IConstituentaID extends Pick{} @@ -431,35 +422,35 @@ export function LoadRSFormData(schema: IRSFormData): IRSForm { ((cst.parse?.status === ParsingStatus.VERIFIED && cst.parse?.valueClass === ValueClass.INVALID) ? 1 : 0) || 0, 0), count_termin: result.items.reduce( - (sum, cst) => (sum + (cst.term?.raw ? 1 : 0) || 0), 0), + (sum, cst) => (sum + (cst.term_raw ? 1 : 0) || 0), 0), count_definition: result.items.reduce( - (sum, cst) => (sum + (cst.definition?.text.raw ? 1 : 0) || 0), 0), + (sum, cst) => (sum + (cst.definition_raw ? 1 : 0) || 0), 0), count_convention: result.items.reduce( (sum, cst) => (sum + (cst.convention ? 1 : 0) || 0), 0), count_base: result.items.reduce( - (sum, cst) => sum + (cst.cstType === CstType.BASE ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.BASE ? 1 : 0), 0), count_constant: result.items?.reduce( - (sum, cst) => sum + (cst.cstType === CstType.CONSTANT ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.CONSTANT ? 1 : 0), 0), count_structured: result.items?.reduce( - (sum, cst) => sum + (cst.cstType === CstType.STRUCTURED ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.STRUCTURED ? 1 : 0), 0), count_axiom: result.items?.reduce( - (sum, cst) => sum + (cst.cstType === CstType.AXIOM ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.AXIOM ? 1 : 0), 0), count_term: result.items.reduce( - (sum, cst) => sum + (cst.cstType === CstType.TERM ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.TERM ? 1 : 0), 0), count_function: result.items.reduce( - (sum, cst) => sum + (cst.cstType === CstType.FUNCTION ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.FUNCTION ? 1 : 0), 0), count_predicate: result.items.reduce( - (sum, cst) => sum + (cst.cstType === CstType.PREDICATE ? 1 : 0), 0), + (sum, cst) => sum + (cst.cst_type === CstType.PREDICATE ? 1 : 0), 0), count_theorem: result.items.reduce( - (sum, cst) => sum + (cst.cstType === CstType.THEOREM ? 1 : 0), 0) + (sum, cst) => sum + (cst.cst_type === CstType.THEOREM ? 1 : 0), 0) } result.items.forEach(cst => { cst.status = inferStatus(cst.parse.status, cst.parse.valueClass); - cst.isTemplate = inferTemplate(cst.definition.formal); - cst.cstClass = inferClass(cst.cstType, cst.isTemplate); + cst.is_template = inferTemplate(cst.definition_formal); + cst.cst_class = inferClass(cst.cst_type, cst.is_template); result.graph.addNode(cst.id); - const dependencies = extractGlobals(cst.definition.formal); + const dependencies = extractGlobals(cst.definition_formal); dependencies.forEach(value => { const source = schema.items.find(cst => cst.alias === value) if (source) { @@ -476,15 +467,15 @@ export function matchConstituenta(query: string, target: IConstituenta, mode: Cs return true; } if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TERM) && - target.term.resolved.match(query)) { + target.term_resolved.match(query)) { return true; } if ((mode === CstMatchMode.ALL || mode === CstMatchMode.EXPR) && - target.definition.formal.match(query)) { + target.definition_formal.match(query)) { return true; } if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TEXT)) { - return (target.definition.text.resolved.match(query) || target.convention.match(query)); + return (target.definition_resolved.match(query) || target.convention.match(query)); } return false; } diff --git a/rsconcept/frontend/src/utils/staticUI.ts b/rsconcept/frontend/src/utils/staticUI.ts index c0ae737a..c563f5c0 100644 --- a/rsconcept/frontend/src/utils/staticUI.ts +++ b/rsconcept/frontend/src/utils/staticUI.ts @@ -16,18 +16,18 @@ export interface IDescriptor { } export function getCstDescription(cst: IConstituenta): string { - if (cst.cstType === CstType.STRUCTURED) { + if (cst.cst_type === CstType.STRUCTURED) { return ( - cst.term.resolved || cst.term.raw || - cst.definition.text.resolved || cst.definition.text.raw || + cst.term_resolved || cst.term_raw || + cst.definition_resolved || cst.definition_raw || cst.convention || - cst.definition.formal + cst.definition_formal ); } else { return ( - cst.term.resolved || cst.term.raw || - cst.definition.text.resolved || cst.definition.text.raw || - cst.definition.formal || + cst.term_resolved || cst.term_raw || + cst.definition_resolved || cst.definition_raw || + cst.definition_formal || cst.convention ); } @@ -51,7 +51,7 @@ export function getCstTypePrefix(type: CstType) { } export function getCstExpressionPrefix(cst: IConstituenta): string { - return cst.alias + (cst.cstType === CstType.STRUCTURED ? '::=' : ':=='); + return cst.alias + (cst.cst_type === CstType.STRUCTURED ? '::=' : ':=='); } export function getRSButtonData(id: TokenID): IDescriptor { @@ -424,7 +424,7 @@ export function createAliasFor(type: CstType, schema: IRSForm): string { return `${prefix}1`; } const index = schema.items.reduce((prev, cst, index) => { - if (cst.cstType !== type) { + if (cst.cst_type !== type) { return prev; } index = Number(cst.alias.slice(1 - cst.alias.length)) + 1; @@ -433,27 +433,23 @@ export function createAliasFor(type: CstType, schema: IRSForm): string { return `${prefix}${index}`; } -export function getMockConstituenta(id: number, alias: string, type: CstType, comment: string): IConstituenta { +export function getMockConstituenta(schema: number, id: number, alias: string, type: CstType, comment: string): IConstituenta { return { id: id, + order: -1, + schema: schema, alias: alias, convention: comment, - cstType: type, - term: { - raw: '', - resolved: '', - forms: [] - }, - definition: { - formal: '', - text: { - raw: '', - resolved: '' - } - }, + cst_type: type, + term_raw: '', + term_resolved: '', + term_forms: [], + definition_formal: '', + definition_raw: '', + definition_resolved: '', status: ExpressionStatus.INCORRECT, - isTemplate: false, - cstClass: CstClass.DERIVED, + is_template: false, + cst_class: CstClass.DERIVED, parse: { status: ParsingStatus.INCORRECT, valueClass: ValueClass.INVALID, @@ -628,6 +624,7 @@ export function getNodeLabel(node: ISyntaxTreeNode): string { case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION' case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION' case TokenID.PUNC_DEFINE: return 'DEFINITION' + case TokenID.PUNC_STRUCT: return 'STRUCTURE_DEFITION' case TokenID.NT_ARG_DECL: return 'ARG' case TokenID.NT_FUNC_CALL: return 'CALL' diff --git a/rsconcept/frontend/vite.config.ts b/rsconcept/frontend/vite.config.ts index 8cc2b4a1..a887be0a 100644 --- a/rsconcept/frontend/vite.config.ts +++ b/rsconcept/frontend/vite.config.ts @@ -1,5 +1,5 @@ import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import { dependencies } from './package.json' @@ -14,20 +14,28 @@ function renderChunks(deps: Record) { } // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - port: 3000 - }, - build: { - chunkSizeWarningLimit: 4000, // KB - sourcemap: false, - rollupOptions: { - output: { - manualChunks: { - ...renderChunks(dependencies), +export default (({ mode }: { mode: string }) => { + process.env = {...process.env, ...loadEnv(mode, process.cwd())}; + const enableHttps = process.env.VITE_PORTAL_FRONT_HTTPS === 'true'; + return defineConfig({ + plugins: [react()], + server: { + port: Number(process.env.VITE_PORTAL_FRONT_PORT), + + // NOTE: https is not used for dev builds currently + https: enableHttps, + }, + build: { + chunkSizeWarningLimit: 4000, // KB + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + // Load chunks for dependencies separately + ...renderChunks(dependencies), + }, }, }, - }, - } -}) + } + }); +}); diff --git a/scripts/dev/LocalDevSetup.ps1 b/scripts/dev/LocalDevSetup.ps1 new file mode 100644 index 00000000..f34dc146 --- /dev/null +++ b/scripts/dev/LocalDevSetup.ps1 @@ -0,0 +1,55 @@ +# Create venv and install dependencies + imports + +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" +$frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\fronted" +$envPath = "$backend\venv" +$python = "$envPath\Scripts\python.exe" + +function LocalDevelopmentSetup() { + FrontendSetup + BackendSetup +} + +function FrontendSetup() { + Set-Location $frontend + & npm install +} + +function BackendSetup() { + Set-Location $backend + + ClearPrevious + CreateEnv + InstallPips + InstallImports +} + +function ClearPrevious() { + if (Test-Path -Path $envPath) { + Write-Host "Removing previous env: $envPath`n" -ForegroundColor DarkGreen + Remove-Item $envPath -Recurse -Force + } +} + +function CreateEnv() { + Write-Host "Creating python env: $envPath`n" -ForegroundColor DarkGreen + & 'python' -m venv $envPath +} + +function InstallPips() { + & $python -m pip install --upgrade pip + & $python -m pip install -r requirements_dev.txt +} + +function InstallImports() { + $wheel = Get-Childitem -Path import\*win*.whl -Name + if (-not $wheel) { + Write-Error 'Missing import wheel' + Exit 1 + } + + Write-Host "Installing wheel: $wheel`n" -ForegroundColor DarkGreen + & $python -m pip install -I import\$wheel +} + +LocalDevelopmentSetup \ No newline at end of file diff --git a/scripts/dev/PopulateDevData.ps1 b/scripts/dev/PopulateDevData.ps1 new file mode 100644 index 00000000..c2e550a0 --- /dev/null +++ b/scripts/dev/PopulateDevData.ps1 @@ -0,0 +1,27 @@ +# Initialize database ! +# FOR DEVELOPEMENT BUILDS ONLY! +$container= Read-Host -Prompt "Enter backend container name: " + +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" + +function PopulateDevData() { + ImportInitialData + CreateAdmin +} + +function ImportInitialData() { + docker exec ` + -it $container ` + python manage.py loaddata $backend\fixtures\InitialData.json +} + +function CreateAdmin() { + docker exec ` + -e DJANGO_SUPERUSER_USERNAME=admin ` + -e DJANGO_SUPERUSER_PASSWORD=1234 ` + -e DJANGO_SUPERUSER_EMAIL=admin@admin.com ` + -it $container python manage.py createsuperuser --noinput +} + +PopulateDevData +pause \ No newline at end of file diff --git a/scripts/dev/RunCoverage.ps1 b/scripts/dev/RunCoverage.ps1 new file mode 100644 index 00000000..8f61f676 --- /dev/null +++ b/scripts/dev/RunCoverage.ps1 @@ -0,0 +1,23 @@ +# Run coverage analysis + +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" + +function RunLinters() { + BackendCoverage +} + +function BackendCoverage() { + Set-Location $backend + + $coverageExec = "$backend\venv\Scripts\coverage.exe" + $djangoSrc = "$backend\manage.py" + $exclude = '*/venv/*,*/tests/*,*/migrations/*,*__init__.py,manage.py,apps.py,urls.py,settings.py' + + & $coverageExec run --omit=$exclude $djangoSrc test + & $coverageExec report + & $coverageExec html + + Start-Process "$backend\htmlcov\index.html" +} + +RunLinters \ No newline at end of file diff --git a/scripts/dev/RunLint.ps1 b/scripts/dev/RunLint.ps1 new file mode 100644 index 00000000..2126cf1a --- /dev/null +++ b/scripts/dev/RunLint.ps1 @@ -0,0 +1,17 @@ +# Run coverage analysis +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" + +function RunLinters() { + BackendLint +} + +function BackendLint() { + $pylint = "$backend\venv\Scripts\pylint.exe" + $mypy = "$backend\venv\Scripts\mypy.exe" + + Set-Location $backend + & $pylint cctext project apps + & $mypy cctext project apps +} + +RunLinters \ No newline at end of file diff --git a/rsconcept/RunServer.ps1 b/scripts/dev/RunServer.ps1 similarity index 78% rename from rsconcept/RunServer.ps1 rename to scripts/dev/RunServer.ps1 index 7b51b6b5..d98b949a 100644 --- a/rsconcept/RunServer.ps1 +++ b/scripts/dev/RunServer.ps1 @@ -1,22 +1,25 @@ - # Run local server +# Run local server Param( [switch] $freshStart ) -$pyExec = "$PSScriptRoot\backend\venv\Scripts\python.exe" -$djangoSrc = "$PSScriptRoot\backend\manage.py" +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" +$frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\frontend" + +$pyExec = "$backend\venv\Scripts\python.exe" +$djangoSrc = "$backend\manage.py" $initialData = "fixtures/InitialData.json" function RunServer() { - RunBackend - RunFrontend + BackendRun + FrontendRun Start-Sleep -Seconds 1 Start-Process "http://localhost:8000/" Start-Process "http://localhost:3000/" } -function RunBackend() { - Set-Location $PSScriptRoot\backend +function BackendRun() { + Set-Location $backend if ($freshStart) { FlushData DoMigrations @@ -30,15 +33,15 @@ function RunBackend() { Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'django'; & $pyExec $djangoSrc runserver }" } -function RunFrontend() { - Set-Location $PSScriptRoot\frontend +function FrontendRun() { + Set-Location $frontend & npm install Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'react'; & npm run dev }" } function FlushData { & $pyExec $djangoSrc flush --noinput - $dbPath = "$PSScriptRoot\backend\db.sqlite3" + $dbPath = "$backend\db.sqlite3" if (Test-Path -Path $dbPath -PathType Leaf) { Remove-Item $dbPath } diff --git a/scripts/dev/RunTests.ps1 b/scripts/dev/RunTests.ps1 new file mode 100644 index 00000000..267dd277 --- /dev/null +++ b/scripts/dev/RunTests.ps1 @@ -0,0 +1,25 @@ +# Run tests + +$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend" +$frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\frontend" + +function RunTests() { + TestBackend + TestFrontend +} + +function TestBackend() { + $pyExec = "$backend\venv\Scripts\python.exe" + $djangoSrc = "$backend\manage.py" + + Set-Location $backend + & $pyExec $djangoSrc check + & $pyExec $djangoSrc test +} + +function TestFrontend() { + Set-Location $frontend + & npm test +} + +RunTests \ No newline at end of file diff --git a/scripts/prod/CreateBackup.ps1 b/scripts/prod/CreateBackup.ps1 new file mode 100644 index 00000000..f6857e7f --- /dev/null +++ b/scripts/prod/CreateBackup.ps1 @@ -0,0 +1,70 @@ +# ====== Create database backup ========== +# WARNING! DO NOT RUN THIS FILE AUTOMATICALLY FROM REPOSITORY LOCATION! +# Create a copy in secure location @production host. Update backup scripts from repository manually +# ======================================== + +# Input params +$backupLocation = "D:\DEV\backup\portal" + +$containerDB = "dev-portal-db" +$containerBackend = "dev-portal-backend" +$pgUser = "portal-admin" +$pgDB = "portal-db" + +# Internal params +$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' +$_date = Get-Date +$_formatDate = $_date.ToString("yyyy-MM-dd") + +$destination = "{0}\{1}" -f $backupLocation, $_formatDate + +function CreateBackup() { + EnsureLocationIsReady + PostgreDump + DjangoDump + Write-Host "Backup saved to $destination" -ForegroundColor DarkGreen +} + +function EnsureLocationIsReady() { + if (Test-Path -Path $destination) { + Write-Host "Removing previous unfinished backup: $destination`n" -ForegroundColor DarkRed + Remove-Item $destination -Recurse -Force + } + New-Item -ItemType Directory -Path $destination | Out-Null + if (Test-Path -Path $archive -PathType Leaf) { + Write-Host "Removing previous backup: $archive`n" -ForegroundColor DarkRed + } +} + +function PostgreDump() { + $host_dbDump = "$destination\$_formatDate-db.dump" + $local_dbDump = "/home/$_formatDate-db.dump" + & docker exec $containerDB ` + pg_dump ` + --username=$pgUser ` + --exclude-table=django_migrations ` + --format=custom ` + --dbname=$pgDB ` + --file=$local_dbDump + & docker cp ${containerDB}:${local_dbDump} $host_dbDump + & docker exec $containerDB rm $local_dbDump +} + +function DjangoDump() { + $host_dataDump = "$destination\$_formatDate-data.json.gz" + $local_dataDump = "/home/app/web/backup/$_formatDate-data.json" + $local_archiveDump = "/home/app/web/backup/$_formatDate-data.json.gz" + & docker exec $containerBackend ` + python manage.py dumpdata ` + --indent=2 ` + --exclude=admin.LogEntry ` + --exclude=sessions ` + --exclude=contenttypes ` + --exclude=auth.permission ` + --output=$local_dataDump + & docker exec $containerBackend gzip --force $local_dataDump + & docker cp ${containerBackend}:${local_archiveDump} $host_dataDump + & docker exec $containerBackend rm $local_archiveDump +} + +CreateBackup \ No newline at end of file diff --git a/scripts/prod/CreateBackup.sh b/scripts/prod/CreateBackup.sh new file mode 100644 index 00000000..7ec8bd03 --- /dev/null +++ b/scripts/prod/CreateBackup.sh @@ -0,0 +1,49 @@ +# ====== Create database backup ========== +# WARNING! DO NOT RUN THIS FILE AUTOMATICALLY FROM REPOSITORY LOCATION! +# Create a copy in secure location @production host. Update backup scripts from repository manually +# ======================================== + +backupLocation="/home/admuser/backup" +pgUser="portal-admin" +pgDB="portal-db" +containerDB="portal-db" +containerBackend="portal-backend" + +dateFmt=$(date '+%Y-%m-%d') +destination="$backupLocation/$dateFmt" + +EnsureLocation() +{ + rm -rf $destination + mkdir $destination +} + +PostgreDump() +{ + dbDump="$destination/$dateFmt-db.dump" + docker exec $containerDB pg_dump \ + --username=$pgUser \ + --exclude-table=django_migrations \ + --format=custom \ + --dbname=$pgDB \ + > $dbDump +} + +DjangoDump() +{ + dataDump="$destination/$dateFmt-data.json" + docker exec $containerBackend \ + python manage.py dumpdata \ + --indent=2 \ + --exclude=admin.LogEntry \ + --exclude=sessions \ + --exclude=contenttypes \ + --exclude=auth.permission \ + > $dataDump + gzip --force $dataDump +} + +EnsureLocation +PostgreDump +DjangoDump +echo "Backup created at: $destination" \ No newline at end of file diff --git a/scripts/prod/LoadDjangoBackup.ps1 b/scripts/prod/LoadDjangoBackup.ps1 new file mode 100644 index 00000000..0008f231 --- /dev/null +++ b/scripts/prod/LoadDjangoBackup.ps1 @@ -0,0 +1,19 @@ +# ====== Load database backup from Django dumpdata ========== +# WARNING! DO NOT RUN THIS FILE AUTOMATICALLY FROM REPOSITORY LOCATION! +# ======================================== + +# Input params +$dataArchive = "D:\DEV\backup\portal\2023-09-01\2023-09-01-data.json.gz" +$target = "local-portal-backend" + +function LoadDjangoBackup() { + $local_archiveDump = "/home/app/web/backup/db-restore.json.gz" + $local_dataDump = "/home/app/web/backup/db-restore.json" + & docker cp ${dataArchive} ${target}:$local_archiveDump + & docker exec $target gzip --decompress --force $local_dataDump + docker exec $target ` + python manage.py loaddata $local_dataDump + & docker exec $target rm $local_dataDump +} + +LoadDjangoBackup \ No newline at end of file diff --git a/scripts/prod/LoadPostgreBackup.ps1 b/scripts/prod/LoadPostgreBackup.ps1 new file mode 100644 index 00000000..b4c53fb3 --- /dev/null +++ b/scripts/prod/LoadPostgreBackup.ps1 @@ -0,0 +1,23 @@ +# ====== Load database backup from PostgreSQL dump ========== +# WARNING! DO NOT RUN THIS FILE AUTOMATICALLY FROM REPOSITORY LOCATION! +# ======================================== + +# Input params +$dataDump = "D:\DEV\backup\portal\2023-09-01\2023-09-01-db.dump" +$target = "dev-portal-db" +$pgUser = "portal-admin" +$pgDB = "portal-db" + +function LoadPostgreBackup() { + $local_dbDump = "/home/db-restore.dump" + & docker cp ${dataDump} ${target}:$local_dbDump + docker exec $target ` + pg_restore ` + --username=$pgUser ` + --dbname=$pgDB ` + --clean ` + $local_dbDump + & docker exec $target rm $local_dbDump +} + +LoadPostgreBackup \ No newline at end of file diff --git a/updateProd.sh b/scripts/prod/UpdateProd.sh similarity index 100% rename from updateProd.sh rename to scripts/prod/UpdateProd.sh