# Conflicts:
#	rsconcept/frontend/src/components/Footer.tsx
#	rsconcept/frontend/src/components/Help/HelpLibrary.tsx
#	rsconcept/frontend/src/pages/RSFormPage/EditorRSForm.tsx
This commit is contained in:
Ulle9 2023-09-02 11:26:45 +03:00
commit 8a596ccccf
70 changed files with 1131 additions and 483 deletions

10
.vscode/launch.json vendored
View File

@ -8,28 +8,28 @@
"name": "Run", "name": "Run",
"type": "PowerShell", "type": "PowerShell",
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1", "script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": [] "args": []
}, },
{ {
"name": "Lint", "name": "Lint",
"type": "PowerShell", "type": "PowerShell",
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunLint.ps1", "script": "${workspaceFolder}/scripts/dev/RunLint.ps1",
"args": [] "args": []
}, },
{ {
"name": "Test", "name": "Test",
"type": "PowerShell", "type": "PowerShell",
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunTests.ps1", "script": "${workspaceFolder}/scripts/dev/RunTests.ps1",
"args": [] "args": []
}, },
{ {
"name": "BE-Coverage", "name": "BE-Coverage",
"type": "PowerShell", "type": "PowerShell",
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunCoverage.ps1", "script": "${workspaceFolder}/scripts/dev/RunCoverage.ps1",
"args": [] "args": []
}, },
{ {
@ -57,7 +57,7 @@
"name": "Restart", "name": "Restart",
"type": "PowerShell", "type": "PowerShell",
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1", "script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": ["-freshStart"] "args": ["-freshStart"]
}, },
{ {

View File

@ -2,20 +2,11 @@
React + Django based web portal for editing RSForm schemas. React + Django based web portal for editing RSForm schemas.
This readme file is used mostly to document project dependencies 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 # Contributing notes
!BEFORE PUSHING INTO MAIN!
- use Test config in VSCode to run tests before pushing commits / requests - 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 - cd rsconcept/frontend & npm run build
- when making major changes make sure that Docker production is building correctly. run 'docker compose -f docker-compose-prod.yml up' - docker compose -f docker-compose-prod.yml up
# Frontend stack & Tooling [Vite + React + Typescript] # Frontend stack & Tooling [Vite + React + Typescript]
<details> <details>
@ -62,11 +53,11 @@ This readme file is used mostly to document project dependencies
<details> <details>
<summary>requirements</summary> <summary>requirements</summary>
<pre> <pre>
- tzdata
- django - django
- djangorestframework - djangorestframework
- django-cors-headers - django-cors-headers
- django-filter - django-filter
- tzdata
- gunicorn - gunicorn
- coreapi - coreapi
- psycopg2-binary - psycopg2-binary
@ -96,3 +87,32 @@ This readme file is used mostly to document project dependencies
# DevOps # DevOps
- Docker compose - Docker compose
- PowerShell - 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'

55
docker-compose-dev.yml Normal file
View File

@ -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

View File

@ -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

View File

@ -26,6 +26,8 @@ services:
- backend - backend
build: build:
context: ./rsconcept/frontend context: ./rsconcept/frontend
args:
BUILD_TYPE: production
expose: expose:
- 3000 - 3000
command: serve -s /home/node -l 3000 command: serve -s /home/node -l 3000
@ -72,6 +74,8 @@ services:
restart: always restart: always
build: build:
context: ./nginx context: ./nginx
args:
BUILD_TYPE: production
ports: ports:
- 8000:8000 - 8000:8000
- 3000:3000 - 3000:3000

View File

@ -1,5 +1,6 @@
FROM nginx:stable-alpine3.17-slim FROM nginx:stable-alpine3.17-slim
ARG BUILD_TYPE=production
# Сopу nginx configuration to the proxy-server # С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/ COPY ./cert/* /etc/ssl/private/

View File

@ -10,7 +10,7 @@ server {
listen 8000 ssl; listen 8000 ssl;
ssl_certificate /etc/ssl/private/front-cert.pem; ssl_certificate /etc/ssl/private/front-cert.pem;
ssl_certificate_key /etc/ssl/private/front-key.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 / { location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -30,7 +30,7 @@ server {
listen 3000 ssl; listen 3000 ssl;
ssl_certificate /etc/ssl/private/front-cert.pem; ssl_certificate /etc/ssl/private/front-cert.pem;
ssl_certificate_key /etc/ssl/private/front-key.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 / { location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -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;
}
}

5
postgresql/.env.dev Normal file
View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,6 @@
# Application settings # Application settings
# SECRET_KEY=
ALLOWED_HOSTS=portal.acconcept.ru;dev.concept.ru 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 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 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_USER=portal-admin
DB_HOST=postgresql-db DB_HOST=postgresql-db
DB_PORT=5432 DB_PORT=5432
# DB_PASSWORD=
# Debug settings # Debug settings

View File

@ -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

View File

@ -46,6 +46,7 @@ RUN mkdir -p $USER_HOME && \
mkdir -p $APP_HOME && \ mkdir -p $APP_HOME && \
mkdir -p $APP_HOME/static && \ mkdir -p $APP_HOME/static && \
mkdir -p $APP_HOME/media && \ mkdir -p $APP_HOME/media && \
mkdir -p $APP_HOME/backup && \
adduser --system --group app adduser --system --group app
# Install python dependencies # Install python dependencies
@ -64,7 +65,8 @@ 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 && \ chown -R app:app $APP_HOME && \
chmod -R a+rwx $APP_HOME/static && \ 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 RUN

View File

@ -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

View File

@ -1,6 +1,4 @@
''' Models: RSForms for conceptual schemas. ''' ''' Models: RSForms for conceptual schemas. '''
import json
from copy import deepcopy
import re import re
from typing import Iterable, Optional, cast from typing import Iterable, Optional, cast
@ -13,7 +11,6 @@ from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
import pyconcept
from apps.users.models import User from apps.users.models import User
from cctext import Resolver, Entity, extract_entities from cctext import Resolver, Entity, extract_entities
from .graph import Graph 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 ''' ''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
if position <= 0: if position <= 0:
raise ValidationError('Invalid position: should be positive integer') 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) update_list = Constituenta.objects.only('id', 'order', 'schema').filter(schema=self.item, order__gte=position)
for cst in update_list: for cst in update_list:
cst.order += 1 cst.order += 1
@ -326,7 +325,6 @@ class RSForm:
alias=alias, alias=alias,
cst_type=insert_type cst_type=insert_type
) )
self.update_order()
self.item.save() self.item.save()
result.refresh_from_db() result.refresh_from_db()
return result return result
@ -343,7 +341,6 @@ class RSForm:
alias=alias, alias=alias,
cst_type=insert_type cst_type=insert_type
) )
self.update_order()
self.item.save() self.item.save()
result.refresh_from_db() result.refresh_from_db()
return result return result
@ -369,7 +366,6 @@ class RSForm:
count_moved += 1 count_moved += 1
update_list.append(cst) update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['order']) Constituenta.objects.bulk_update(update_list, ['order'])
self.update_order()
self.item.save() self.item.save()
@transaction.atomic @transaction.atomic
@ -377,7 +373,7 @@ class RSForm:
''' Delete multiple constituents. Do not check if listCst are from this schema ''' ''' Delete multiple constituents. Do not check if listCst are from this schema '''
for cst in listCst: for cst in listCst:
cst.delete() cst.delete()
self.update_order() self._reset_order()
self.resolve_all_text() self.resolve_all_text()
self.item.save() self.item.save()
@ -447,23 +443,6 @@ class RSForm:
if modified: if modified:
cst.save() 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 @transaction.atomic
def resolve_all_text(self): def resolve_all_text(self):
''' Trigger reference resolution for all texts. ''' ''' Trigger reference resolution for all texts. '''
@ -482,6 +461,15 @@ class RSForm:
cst.definition_resolved = resolved cst.definition_resolved = resolved
cst.save() 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': def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta':
if insert_after is not None: if insert_after is not None:
cstafter = Constituenta.objects.get(pk=insert_after) cstafter = Constituenta.objects.get(pk=insert_after)
@ -510,87 +498,3 @@ class RSForm:
if result.contains(alias): if result.contains(alias):
result.add_edge(id_from=alias, id_to=cst.alias) result.add_edge(id_from=alias, id_to=cst.alias)
return result 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']
})

View File

@ -1,8 +1,10 @@
''' Serializers for conceptual schema API. ''' ''' Serializers for conceptual schema API. '''
import json
from typing import Optional, cast from typing import Optional, cast
from rest_framework import serializers from rest_framework import serializers
from django.db import transaction from django.db import transaction
import pyconcept
from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference
from .utils import fix_old_references from .utils import fix_old_references
@ -31,7 +33,7 @@ class TextSerializer(serializers.Serializer):
class LibraryItemSerializer(serializers.ModelSerializer): class LibraryItemSerializer(serializers.ModelSerializer):
''' Serializer: Library item data. ''' ''' Serializer: LibraryItem entry. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = LibraryItem model = LibraryItem
@ -39,6 +41,71 @@ class LibraryItemSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id', 'item_type') 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): class RSFormSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm. ''' ''' Serializer: Detailed data for RSForm. '''
class Meta: class Meta:
@ -46,13 +113,30 @@ class RSFormSerializer(serializers.ModelSerializer):
model = RSForm model = RSForm
def to_representation(self, instance: RSForm): def to_representation(self, instance: RSForm):
result = LibraryItemSerializer(instance.item).data result = LibraryItemDetailsSerializer(instance.item).data
result['items'] = [] result['items'] = []
for cst in instance.constituents().order_by('order'): for cst in instance.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data) result['items'].append(ConstituentaSerializer(cst).data)
return result 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): class RSFormUploadSerializer(serializers.Serializer):
''' Upload data for RSForm serializer. ''' ''' Upload data for RSForm serializer. '''
file = serializers.FileField() file = serializers.FileField()
@ -193,7 +277,6 @@ class RSFormTRSSerializer(serializers.Serializer):
if prev_cst.pk not in loaded_ids: if prev_cst.pk not in loaded_ids:
prev_cst.delete() prev_cst.delete()
instance.update_order()
instance.resolve_all_text() instance.resolve_all_text()
instance.item.save() instance.item.save()
return instance return instance

View File

@ -200,10 +200,10 @@ class TestRSForm(TestCase):
d2 = schema.insert_at(1, 'D2', CstType.TERM) d2 = schema.insert_at(1, 'D2', CstType.TERM)
d1.refresh_from_db() d1.refresh_from_db()
self.assertEqual(d1.order, 3) self.assertEqual(d1.order, 3)
self.assertEqual(d2.order, 2) self.assertEqual(d2.order, 1)
x2 = schema.insert_at(4, 'X2', CstType.BASE) x2 = schema.insert_at(4, 'X2', CstType.BASE)
self.assertEqual(x2.order, 2) self.assertEqual(x2.order, 4)
def test_insert_last(self): def test_insert_last(self):
schema = RSForm.create(title='Test') schema = RSForm.create(title='Test')
@ -259,10 +259,10 @@ class TestRSForm(TestCase):
x2.refresh_from_db() x2.refresh_from_db()
d1.refresh_from_db() d1.refresh_from_db()
d2.refresh_from_db() d2.refresh_from_db()
self.assertEqual(x1.order, 2) self.assertEqual(x1.order, 3)
self.assertEqual(x2.order, 1) self.assertEqual(x2.order, 1)
self.assertEqual(d1.order, 4) self.assertEqual(d1.order, 4)
self.assertEqual(d2.order, 3) self.assertEqual(d2.order, 2)
def test_move_cst_down(self): def test_move_cst_down(self):
schema = RSForm.create(title='Test') schema = RSForm.create(title='Test')

View File

@ -213,10 +213,14 @@ class TestLibraryViewset(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(_response_contains(response, self.unowned)) self.assertFalse(_response_contains(response, self.unowned))
user2 = User.objects.create(username='UserTest2')
Subscription.subscribe(user=self.user, item=self.unowned) 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') response = self.client.get('/api/library/active')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(_response_contains(response, self.unowned)) self.assertTrue(_response_contains(response, self.unowned))
self.assertEqual(len(response.data), 3)
def test_subscriptions(self): def test_subscriptions(self):
response = self.client.delete(f'/api/library/{self.unowned.id}/unsubscribe') 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(len(response.data['items']), 2)
self.assertEqual(response.data['items'][0]['id'], x1.id) self.assertEqual(response.data['items'][0]['id'], x1.id)
self.assertEqual(response.data['items'][0]['parse']['status'], 'verified') 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_raw'], x1.term_raw)
self.assertEqual(response.data['items'][0]['term']['resolved'], x1.term_resolved) 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]['id'], x2.id)
self.assertEqual(response.data['items'][1]['term']['raw'], x2.term_raw) 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_resolved'], x2.term_resolved)
self.assertEqual(response.data['subscribers'], [self.user.pk]) self.assertEqual(response.data['subscribers'], [self.user.pk])
def test_check(self): def test_check(self):
@ -412,6 +416,7 @@ class TestRSFormViewset(APITestCase):
d1.definition_formal = 'X1' d1.definition_formal = 'X1'
d1.save() d1.save()
self.assertEqual(d1.order, 4)
self.assertEqual(self.cst1.order, 1) self.assertEqual(self.cst1.order, 1)
self.assertEqual(self.cst1.alias, 'X1') self.assertEqual(self.cst1.alias, 'X1')
self.assertEqual(self.cst1.cst_type, CstType.BASE) self.assertEqual(self.cst1.cst_type, CstType.BASE)
@ -422,9 +427,10 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(response.data['new_cst']['cst_type'], 'term') self.assertEqual(response.data['new_cst']['cst_type'], 'term')
d1.refresh_from_db() d1.refresh_from_db()
self.cst1.refresh_from_db() self.cst1.refresh_from_db()
self.assertEqual(d1.order, 4)
self.assertEqual(d1.term_resolved, '') self.assertEqual(d1.term_resolved, '')
self.assertEqual(d1.term_raw, '@{D2|plur}') 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.alias, 'D2')
self.assertEqual(self.cst1.cst_type, CstType.TERM) self.assertEqual(self.cst1.cst_type, CstType.TERM)
@ -560,10 +566,10 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title') self.assertEqual(response.data['title'], 'Title')
self.assertEqual(response.data['items'][0]['alias'], x1.alias) 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_raw'], x1.term_raw)
self.assertEqual(response.data['items'][0]['term']['resolved'], x1.term_resolved) 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_raw'], d1.term_raw)
self.assertEqual(response.data['items'][1]['term']['resolved'], d1.term_resolved) self.assertEqual(response.data['items'][1]['term_resolved'], d1.term_resolved)
class TestFunctionalViews(APITestCase): class TestFunctionalViews(APITestCase):

View File

@ -25,7 +25,9 @@ class LibraryActiveView(generics.ListAPIView):
user = self.request.user user = self.request.user
if not user.is_anonymous: if not user.is_anonymous:
# pylint: disable=unsupported-binary-operation # 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: else:
return m.LibraryItem.objects.filter(is_common=True) 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 = s.RSFormTRSSerializer(data=clone_data, context={'load_meta': True})
clone.is_valid(raise_exception=True) clone.is_valid(raise_exception=True)
new_schema = clone.save() 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) return Response(status=404)
@transaction.atomic @transaction.atomic
@ -153,7 +155,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
schema.item.refresh_from_db() schema.item.refresh_from_db()
response = Response(status=201, data={ response = Response(status=201, data={
'new_cst': s.ConstituentaSerializer(new_cst).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() response['Location'] = new_cst.get_absolute_url()
return response return response
@ -169,12 +171,11 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer.save() serializer.save()
mapping = { old_alias: serializer.validated_data['alias'] } mapping = { old_alias: serializer.validated_data['alias'] }
schema.apply_mapping(mapping, change_aliases=False) schema.apply_mapping(mapping, change_aliases=False)
schema.update_order()
schema.item.refresh_from_db() schema.item.refresh_from_db()
cst = m.Constituenta.objects.get(pk=serializer.validated_data['id']) cst = m.Constituenta.objects.get(pk=serializer.validated_data['id'])
return Response(status=200, data={ return Response(status=200, data={
'new_cst': s.ConstituentaSerializer(cst).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') @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) serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents']) schema.delete_cst(serializer.validated_data['constituents'])
schema.item.refresh_from_db() 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') @action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request, pk): def cst_moveto(self, request, pk):
@ -195,14 +196,14 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to']) schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
schema.item.refresh_from_db() 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') @action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request, pk): def reset_aliases(self, request, pk):
''' Endpoint: Recreate all aliases based on order. ''' ''' Endpoint: Recreate all aliases based on order. '''
schema = self._get_schema() schema = self._get_schema()
schema.reset_aliases() 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') @action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request, pk): 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 = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema = serializer.save() 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']) @action(detail=True, methods=['get'])
def contents(self, request, pk): def contents(self, request, pk):
@ -229,27 +230,26 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def details(self, request, pk): def details(self, request, pk):
''' Endpoint: Detailed schema view including statuses and parse. ''' ''' Endpoint: Detailed schema view including statuses and parse. '''
schema = self._get_schema() schema = self._get_schema()
serializer = m.PyConceptAdapter(schema) serializer = s.RSFormParseSerializer(schema)
return Response(serializer.full()) return Response(serializer.data)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def check(self, request, pk): def check(self, request, pk):
''' Endpoint: Check RSLang expression against schema context. ''' ''' Endpoint: Check RSLang expression against schema context. '''
schema = m.PyConceptAdapter(self._get_schema())
serializer = s.ExpressionSerializer(data=request.data) serializer = s.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression'] expression = serializer.validated_data['expression']
schema = s.PyConceptAdapter(self._get_schema())
result = pyconcept.check_expression(json.dumps(schema.data), expression) result = pyconcept.check_expression(json.dumps(schema.data), expression)
return Response(json.loads(result)) return Response(json.loads(result))
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def resolve(self, request, pk): def resolve(self, request, pk):
''' Endpoint: Resolve refenrces in text against schema terms context. ''' ''' Endpoint: Resolve refenrces in text against schema terms context. '''
schema = self._get_schema()
serializer = s.TextSerializer(data=request.data) serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text'] text = serializer.validated_data['text']
resolver = schema.resolver() resolver = self._get_schema().resolver()
resolver.resolve(text) resolver.resolve(text)
return Response(status=200, data=s.ResolverSerializer(resolver).data) return Response(status=200, data=s.ResolverSerializer(resolver).data)

View File

@ -1,3 +1,4 @@
# Dev specific # Dev specific
.gitignore .gitignore
node_modules node_modules
.env.local

View File

@ -0,0 +1,5 @@
# Local build config
VITE_PORTAL_BACKEND=http://localhost:8000
VITE_PORTAL_FRONT_PORT=3000
VITE_PORTAL_FRONT_HTTPS=false

View File

@ -5,11 +5,14 @@ RUN apt-get update -qq && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# ======= Build ======= # ======= Build =======
ARG BUILD_TYPE=production
FROM node-base as builder FROM node-base as builder
WORKDIR /result WORKDIR /result
COPY ./ ./ COPY ./ ./
COPY ./env/.env.$BUILD_TYPE ./
RUN rm -rf ./env
RUN npm install RUN npm install
ENV NODE_ENV production ENV NODE_ENV production
RUN npm run build RUN npm run build

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -18,7 +18,7 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'; const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer';
function handleLabelClick(event: React.MouseEvent<HTMLLabelElement, MouseEvent>): void { function handleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void {
event.preventDefault(); event.preventDefault();
if (!disabled) { if (!disabled) {
inputRef.current?.click(); inputRef.current?.click();
@ -26,7 +26,12 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
} }
return ( return (
<div className={'flex gap-2 [&:not(:first-child)]:mt-3 ' + widthClass} title={tooltip}> <button
className={'flex gap-2 [&:not(:first-child)]:mt-3 ' + widthClass}
title={tooltip}
disabled={disabled}
onClick={handleClick}
>
<input id={id} type='checkbox' ref={inputRef} <input id={id} type='checkbox' ref={inputRef}
className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox ${cursor}`} className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox ${cursor}`}
required={required} required={required}
@ -40,7 +45,6 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
text={label} text={label}
required={required} required={required}
htmlFor={id} htmlFor={id}
onClick={handleLabelClick}
/>} />}
<svg <svg
className='absolute hidden w-3 h-3 mt-1 ml-0.5 text-white pointer-events-none peer-checked:block' className='absolute hidden w-3 h-3 mt-1 ml-0.5 text-white pointer-events-none peer-checked:block'
@ -49,7 +53,7 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
> >
<path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' /> <path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' />
</svg> </svg>
</div> </button>
); );
} }

View File

@ -7,7 +7,7 @@ interface DropdownProps {
function Dropdown({ children, widthClass = 'w-fit', stretchLeft }: DropdownProps) { function Dropdown({ children, widthClass = 'w-fit', stretchLeft }: DropdownProps) {
return ( return (
<div className='relative text-sm'> <div className='relative text-sm'>
<div className={`absolute ${stretchLeft ? 'right-0' : 'left-0'} mt-2 z-40 flex flex-col items-stretch justify-start origin-top-right border divide-y rounded-md shadow-lg clr-input clr-border ${widthClass}`}> <div className={`absolute ${stretchLeft ? 'right-0' : 'left-0'} mt-2 py-1 z-40 flex flex-col items-stretch justify-start origin-top-right border divide-y rounded-md shadow-lg clr-input clr-border ${widthClass}`}>
{children} {children}
</div> </div>
</div> </div>

View File

@ -1,16 +1,16 @@
interface NavigationTextItemProps { interface DropdownButtonProps {
description?: string | undefined tooltip?: string | undefined
onClick?: () => void onClick?: () => void
disabled?: boolean disabled?: boolean
children: React.ReactNode children: React.ReactNode
} }
function DropdownButton({ description = '', onClick, disabled, children }: NavigationTextItemProps) { function DropdownButton({ tooltip, onClick, disabled, children }: DropdownButtonProps) {
const behavior = (onClick ? 'cursor-pointer clr-hover' : 'cursor-default') + ' disabled:cursor-not-allowed'; const behavior = (onClick ? 'cursor-pointer disabled:cursor-not-allowed clr-hover' : 'cursor-default');
return ( return (
<button <button
disabled={disabled} disabled={disabled}
title={description} title={tooltip}
onClick={onClick} onClick={onClick}
className={`px-3 py-1 text-left overflow-ellipsis ${behavior} whitespace-nowrap`} className={`px-3 py-1 text-left overflow-ellipsis ${behavior} whitespace-nowrap`}
> >

View File

@ -0,0 +1,28 @@
import Checkbox from './Checkbox';
interface DropdownCheckboxProps {
label?: string
tooltip?: string
disabled?: boolean
value?: boolean
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
function DropdownCheckbox({ tooltip, onChange, disabled, ...props }: DropdownCheckboxProps) {
const behavior = (onChange && !disabled ? 'clr-hover' : '');
return (
<div
title={tooltip}
className={`px-4 py-1 text-left overflow-ellipsis ${behavior} whitespace-nowrap`}
>
<Checkbox
widthClass='w-fit'
disabled={disabled}
onChange={onChange}
{...props}
/>
</div>
);
}
export default DropdownCheckbox;

View File

@ -4,7 +4,7 @@ function HelpRSFormMeta() {
<h1>Паспорт схемы</h1> <h1>Паспорт схемы</h1>
<p><b>Владелец</b> - пользователь, обладающий правом редактирования</p> <p><b>Владелец</b> - пользователь, обладающий правом редактирования</p>
<p>Для <b>общедоступных</b> схем владельцем может стать любой пользователь</p> <p>Для <b>общедоступных</b> схем владельцем может стать любой пользователь</p>
<p>Для <b>библиотечных</b> схем правом редактирования обладают только администраторы</p> <p>Для <b>не</b> схем правом редактирования обладают только администраторы</p>
<p><b>Клонировать</b> - создать копию схемы для дальнейшего редактирования</p> <p><b>Клонировать</b> - создать копию схемы для дальнейшего редактирования</p>
<p><b>Отслеживание</b> - возможность видеть схему в Библиотеке и использовать фильтры</p> <p><b>Отслеживание</b> - возможность видеть схему в Библиотеке и использовать фильтры</p>
<p><b>Загрузить/Выгрузить схему</b> - взаимодействие с Экстеор через файлы формата TRS</p> <p><b>Загрузить/Выгрузить схему</b> - взаимодействие с Экстеор через файлы формата TRS</p>

View File

@ -11,9 +11,9 @@ function InfoConstituenta({ data, ...props }: InfoConstituentaProps) {
<div {...props}> <div {...props}>
<h1>Конституента {data.alias}</h1> <h1>Конституента {data.alias}</h1>
<p><b>Типизация: </b>{getCstTypificationLabel(data)}</p> <p><b>Типизация: </b>{getCstTypificationLabel(data)}</p>
<p><b>Термин: </b>{data.term.resolved || data.term.raw}</p> <p><b>Термин: </b>{data.term_resolved || data.term_raw}</p>
{data.definition.formal && <p><b>Выражение: </b>{data.definition.formal}</p>} {data.definition_formal && <p><b>Выражение: </b>{data.definition_formal}</p>}
{data.definition.text.resolved && <p><b>Определение: </b>{data.definition.text.resolved}</p>} {data.definition_resolved && <p><b>Определение: </b>{data.definition_resolved}</p>}
{data.convention && <p><b>Конвенция: </b>{data.convention}</p>} {data.convention && <p><b>Конвенция: </b>{data.convention}</p>}
</div> </div>
); );

View File

@ -34,13 +34,13 @@ function UserDropdown({ hideDropdown }: UserDropdownProps) {
return ( return (
<Dropdown widthClass='w-36' stretchLeft> <Dropdown widthClass='w-36' stretchLeft>
<DropdownButton <DropdownButton
description='Профиль пользователя' tooltip='Профиль пользователя'
onClick={navigateProfile} onClick={navigateProfile}
> >
{user?.username} {user?.username}
</DropdownButton> </DropdownButton>
<DropdownButton <DropdownButton
description='Переключение темы оформления' tooltip='Переключение темы оформления'
onClick={toggleDarkMode} onClick={toggleDarkMode}
> >
{darkMode ? 'Светлая тема' : 'Темная тема'} {darkMode ? 'Светлая тема' : 'Темная тема'}

View File

@ -13,7 +13,7 @@ import Label from '../Common/Label';
import { ccBracketMatching } from './bracketMatching'; import { ccBracketMatching } from './bracketMatching';
import { RSLanguage } from './rslang'; import { RSLanguage } from './rslang';
import { getSymbolSubstitute,TextWrapper } from './textEditing'; import { getSymbolSubstitute,TextWrapper } from './textEditing';
import { rshoverTooltip } from './tooltip'; import { rshoverTooltip as rsHoverTooltip } from './tooltip';
const editorSetup: BasicSetupOptions = { const editorSetup: BasicSetupOptions = {
highlightSpecialChars: false, highlightSpecialChars: false,
@ -111,7 +111,7 @@ function RSInput({
EditorView.lineWrapping, EditorView.lineWrapping,
RSLanguage, RSLanguage,
ccBracketMatching(darkMode), ccBracketMatching(darkMode),
rshoverTooltip(schema?.items || []), rsHoverTooltip(schema?.items || []),
], [darkMode, schema?.items]); ], [darkMode, schema?.items]);
const handleInput = useCallback( const handleInput = useCallback(

View File

@ -6,24 +6,23 @@ import { getCstTypificationLabel } from '../../utils/staticUI';
function createTooltipFor(cst: IConstituenta) { function createTooltipFor(cst: IConstituenta) {
const dom = document.createElement('div'); const dom = document.createElement('div');
dom.className = 'overflow-y-auto border shadow-md max-h-[25rem] max-w-[25rem] min-w-[10rem] w-fit z-20 text-sm'; dom.className = 'overflow-y-auto border shadow-md max-h-[25rem] max-w-[25rem] min-w-[10rem] w-fit z-20 text-sm clr-border px-2 py-2';
const alias = document.createElement('h1'); const alias = document.createElement('p');
alias.className = 'text-sm text-left'; alias.innerHTML = `<b>${cst.alias}:</b> ${getCstTypificationLabel(cst)}`;
alias.textContent = `${cst.alias}: ${getCstTypificationLabel(cst)}`;
dom.appendChild(alias); dom.appendChild(alias);
if (cst.term.resolved) { if (cst.term_resolved) {
const term = document.createElement('p'); const term = document.createElement('p');
term.innerHTML = `<b>Термин:</b> ${cst.term.resolved}`; term.innerHTML = `<b>Термин:</b> ${cst.term_resolved}`;
dom.appendChild(term); dom.appendChild(term);
} }
if (cst.definition.formal) { if (cst.definition_formal) {
const expression = document.createElement('p'); const expression = document.createElement('p');
expression.innerHTML = `<b>Выражение:</b> ${cst.definition.formal}`; expression.innerHTML = `<b>Выражение:</b> ${cst.definition_formal}`;
dom.appendChild(expression); dom.appendChild(expression);
} }
if (cst.definition.text.resolved) { if (cst.definition_resolved) {
const definition = document.createElement('p'); const definition = document.createElement('p');
definition.innerHTML = `<b>Определение:</b> ${cst.definition.text.resolved}`; definition.innerHTML = `<b>Определение:</b> ${cst.definition_resolved}`;
dom.appendChild(definition); dom.appendChild(definition);
} }
if (cst.convention) { if (cst.convention) {

View File

@ -58,14 +58,14 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
const mainHeight = useMemo( const mainHeight = useMemo(
() => { () => {
return !noNavigation ? return !noNavigation ?
'calc(100vh - 8rem)' 'calc(100vh - 7rem - 2px)'
: '100vh'; : '100vh';
}, [noNavigation]); }, [noNavigation]);
const viewportHeight = useMemo( const viewportHeight = useMemo(
() => { () => {
return !noNavigation ? return !noNavigation ?
'calc(100vh - 3.9rem)' 'calc(100vh - 3rem - 2px)'
: '100vh'; : '100vh';
}, [noNavigation]); }, [noNavigation]);

View File

@ -70,7 +70,7 @@ function useCheckExpression({ schema }: { schema?: IRSForm }) {
onError: error => setError(error), onError: error => setError(error),
onSuccess: parse => { onSuccess: parse => {
if (activeCst) { if (activeCst) {
adjustResults(parse, expression === getCstExpressionPrefix(activeCst), activeCst.cstType); adjustResults(parse, expression === getCstExpressionPrefix(activeCst), activeCst.cst_type);
} }
setParseData(parse); setParseData(parse);
if (onSuccess) onSuccess(parse); if (onSuccess) onSuccess(parse);

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import Button from '../components/Common/Button';
import Checkbox from '../components/Common/Checkbox'; import Checkbox from '../components/Common/Checkbox';
import FileInput from '../components/Common/FileInput'; import FileInput from '../components/Common/FileInput';
import Form from '../components/Common/Form'; import Form from '../components/Common/Form';
@ -14,6 +15,7 @@ import { useLibrary } from '../context/LibraryContext';
import { IRSFormCreateData, LibraryItemType } from '../utils/models'; import { IRSFormCreateData, LibraryItemType } from '../utils/models';
function CreateRSFormPage() { function CreateRSFormPage() {
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { createSchema, error, setError, processing } = useLibrary(); const { createSchema, error, setError, processing } = useLibrary();
@ -35,6 +37,14 @@ function CreateRSFormPage() {
} }
} }
function handleCancel() {
if (location.key !== "default") {
navigate(-1);
} else {
navigate('/library');
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (processing) { if (processing) {
@ -89,10 +99,16 @@ function CreateRSFormPage() {
onChange={handleFile} onChange={handleFile}
/> />
<div className='flex items-center justify-center py-2 mt-4'> <div className='flex items-center justify-center gap-4 py-2 mt-4'>
<SubmitButton <SubmitButton
text='Создать схему' text='Создать схему'
loading={processing} loading={processing}
widthClass='min-w-[10rem]'
/>
<Button
text='Отмена'
onClick={() => handleCancel()}
widthClass='min-w-[10rem]'
/> />
</div> </div>
{ error && <BackendError error={error} />} { error && <BackendError error={error} />}

View File

@ -13,7 +13,7 @@ function HomePage() {
setTimeout(() => { setTimeout(() => {
navigate('/manuals'); navigate('/manuals');
}, TIMEOUT_UI_REFRESH); }, TIMEOUT_UI_REFRESH);
} else if(!user.is_staff) { } else {
setTimeout(() => { setTimeout(() => {
navigate('/library'); navigate('/library');
}, TIMEOUT_UI_REFRESH); }, TIMEOUT_UI_REFRESH);

View File

@ -1,10 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import Button from '../../components/Common/Button'; import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox';
import Dropdown from '../../components/Common/Dropdown'; import Dropdown from '../../components/Common/Dropdown';
import DropdownButton from '../../components/Common/DropdownButton'; import DropdownCheckbox from '../../components/Common/DropdownCheckbox';
import { FilterCogIcon } from '../../components/Icons'; import { FilterCogIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import useDropdown from '../../hooks/useDropdown'; import useDropdown from '../../hooks/useDropdown';
import { LibraryFilterStrategy } from '../../utils/models'; import { LibraryFilterStrategy } from '../../utils/models';
@ -15,6 +15,7 @@ interface PickerStrategyProps {
function PickerStrategy({ value, onChange }: PickerStrategyProps) { function PickerStrategy({ value, onChange }: PickerStrategyProps) {
const pickerMenu = useDropdown(); const pickerMenu = useDropdown();
const { user } = useAuth();
const handleChange = useCallback( const handleChange = useCallback(
(newValue: LibraryFilterStrategy) => { (newValue: LibraryFilterStrategy) => {
@ -34,53 +35,44 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) {
/> />
{ pickerMenu.isActive && { pickerMenu.isActive &&
<Dropdown> <Dropdown>
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.MANUAL)}> <DropdownCheckbox
<Checkbox onChange={() => handleChange(LibraryFilterStrategy.MANUAL)}
value={value === LibraryFilterStrategy.MANUAL} value={value === LibraryFilterStrategy.MANUAL}
label='Отображать все' label='Отображать все'
widthClass='w-fit px-2'
/> />
</DropdownButton> <DropdownCheckbox
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.COMMON)}> onChange={() => handleChange(LibraryFilterStrategy.COMMON)}
<Checkbox
value={value === LibraryFilterStrategy.COMMON} value={value === LibraryFilterStrategy.COMMON}
label='Общедоступные' label='Общедоступные'
widthClass='w-fit px-2'
tooltip='Отображать только общедоступные схемы' tooltip='Отображать только общедоступные схемы'
/> />
</DropdownButton> <DropdownCheckbox
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.CANONICAL)}> onChange={() => handleChange(LibraryFilterStrategy.CANONICAL)}
<Checkbox
value={value === LibraryFilterStrategy.CANONICAL} value={value === LibraryFilterStrategy.CANONICAL}
label='Библиотечные' label='Неизменные'
widthClass='w-fit px-2' tooltip='Отображать только неизменные схемы'
tooltip='Отображать только библиотечные схемы'
/> />
</DropdownButton> <DropdownCheckbox
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.PERSONAL)}> onChange={() => handleChange(LibraryFilterStrategy.PERSONAL)}
<Checkbox
value={value === LibraryFilterStrategy.PERSONAL} value={value === LibraryFilterStrategy.PERSONAL}
label='Личные' label='Личные'
widthClass='w-fit px-2' disabled={!user}
tooltip='Отображать только подписки и владеемые схемы' tooltip='Отображать только подписки и владеемые схемы'
/> />
</DropdownButton> <DropdownCheckbox
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.SUBSCRIBE)}> onChange={() => handleChange(LibraryFilterStrategy.SUBSCRIBE)}
<Checkbox
value={value === LibraryFilterStrategy.SUBSCRIBE} value={value === LibraryFilterStrategy.SUBSCRIBE}
label='Подписки' label='Подписки'
widthClass='w-fit px-2' disabled={!user}
tooltip='Отображать только подписки' tooltip='Отображать только подписки'
/> />
</DropdownButton> <DropdownCheckbox
<DropdownButton onClick={() => handleChange(LibraryFilterStrategy.OWNED)}> onChange={() => handleChange(LibraryFilterStrategy.OWNED)}
<Checkbox
value={value === LibraryFilterStrategy.OWNED} value={value === LibraryFilterStrategy.OWNED}
disabled={!user}
label='Я - Владелец!' label='Я - Владелец!'
widthClass='w-fit px-2'
tooltip='Отображать только владеемые схемы' tooltip='Отображать только владеемые схемы'
/> />
</DropdownButton>
</Dropdown>} </Dropdown>}
</div> </div>
); );

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { MagnifyingGlassIcon } from '../../components/Icons'; import { MagnifyingGlassIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import useLocalStorage from '../../hooks/useLocalStorage';
import { ILibraryFilter, LibraryFilterStrategy } from '../../utils/models'; import { ILibraryFilter, LibraryFilterStrategy } from '../../utils/models';
import PickerStrategy from './PickerStrategy'; import PickerStrategy from './PickerStrategy';
@ -30,7 +31,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
const { user } = useAuth(); const { user } = useAuth();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [strategy, setStrategy] = useState(LibraryFilterStrategy.MANUAL); const [strategy, setStrategy] = useLocalStorage<LibraryFilterStrategy>('search_strategy', LibraryFilterStrategy.MANUAL);
function handleChangeQuery(event: React.ChangeEvent<HTMLInputElement>) { function handleChangeQuery(event: React.ChangeEvent<HTMLInputElement>) {
const newQuery = event.target.value; const newQuery = event.target.value;
@ -49,11 +50,15 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
useLayoutEffect(() => { useLayoutEffect(() => {
const searchFilter = new URLSearchParams(search).get('filter') as LibraryFilterStrategy | null; 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; const inputStrategy = searchFilter && Object.values(LibraryFilterStrategy).includes(searchFilter) ? searchFilter : LibraryFilterStrategy.MANUAL;
setQuery('') setQuery('')
setStrategy(inputStrategy) setStrategy(inputStrategy)
setFilter(ApplyStrategy(inputStrategy)); setFilter(ApplyStrategy(inputStrategy));
}, [user, search, setQuery, setFilter]); }, [user, search, setQuery, setFilter, setStrategy, strategy, navigate]);
const handleChangeStrategy = useCallback( const handleChangeStrategy = useCallback(
(value: LibraryFilterStrategy) => { (value: LibraryFilterStrategy) => {
@ -64,7 +69,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
}, [strategy, navigate]); }, [strategy, navigate]);
return ( return (
<div className='sticky top-0 left-0 right-0 z-10 flex items-center justify-start w-full border-b clr-input'> <div className='sticky top-0 left-0 right-0 z-30 flex items-center justify-start w-full border-b clr-input'>
<div className='px-2 py-1 select-none whitespace-nowrap min-w-[10rem]'> <div className='px-2 py-1 select-none whitespace-nowrap min-w-[10rem]'>
Фильтр Фильтр
<span className='ml-2'> <span className='ml-2'>

View File

@ -10,6 +10,7 @@ import { useAuth } from '../context/AuthContext';
import { IUserLoginData } from '../utils/models'; import { IUserLoginData } from '../utils/models';
function LoginPage() { function LoginPage() {
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const search = useLocation().search; const search = useLocation().search;
const { user, login, loading, error, setError } = useAuth(); const { user, login, loading, error, setError } = useAuth();
@ -34,7 +35,13 @@ function LoginPage() {
username: username, username: username,
password: password password: password
}; };
login(data, () => navigate('/library')); login(data, () => {
if (location.key !== "default") {
navigate(-1);
} else {
navigate('/library');
}
});
} }
} }
@ -44,7 +51,7 @@ function LoginPage() {
? <b>{`Вы вошли в систему как ${user.username}`}</b> ? <b>{`Вы вошли в систему как ${user.username}`}</b>
: :
<Form <Form
title='Ввод данных пользователя' title='Вход в Портал'
onSubmit={handleSubmit} onSubmit={handleSubmit}
widthClass='w-[24rem]' widthClass='w-[24rem]'
> >
@ -64,10 +71,10 @@ function LoginPage() {
onChange={event => setPassword(event.target.value)} onChange={event => setPassword(event.target.value)}
/> />
<div className='flex justify-center w-full gap-2 mt-4'> <div className='flex justify-center w-full gap-2 py-2 mt-4'>
<SubmitButton <SubmitButton
text='Вход' text='Вход'
widthClass='w-[7rem]' widthClass='w-[12rem]'
loading={loading} loading={loading}
/> />
</div> </div>

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useState } from 'react'; import { Dispatch, SetStateAction, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import ConceptTooltip from '../../components/Common/ConceptTooltip'; import ConceptTooltip from '../../components/Common/ConceptTooltip';
@ -9,7 +9,6 @@ import TextArea from '../../components/Common/TextArea';
import HelpConstituenta from '../../components/Help/HelpConstituenta'; import HelpConstituenta from '../../components/Help/HelpConstituenta';
import { DumpBinIcon, HelpIcon, PenIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons'; import { DumpBinIcon, HelpIcon, PenIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { CstType, EditMode, ICstCreateData, ICstRenameData, ICstUpdateData, SyntaxTree } from '../../utils/models'; import { CstType, EditMode, ICstCreateData, ICstRenameData, ICstUpdateData, SyntaxTree } from '../../utils/models';
import { getCstTypificationLabel } from '../../utils/staticUI'; import { getCstTypificationLabel } from '../../utils/staticUI';
import EditorRSExpression from './EditorRSExpression'; import EditorRSExpression from './EditorRSExpression';
@ -25,17 +24,20 @@ interface EditorConstituentaProps {
onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void
onRenameCst: (initial: ICstRenameData) => void onRenameCst: (initial: ICstRenameData) => void
onDeleteCst: (selected: number[], callback?: (items: number[]) => void) => void onDeleteCst: (selected: number[], callback?: (items: number[]) => void) => void
isModified: boolean
setIsModified: Dispatch<SetStateAction<boolean>>
} }
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 { schema, processing, isEditable, cstUpdate } = useRSForm();
const activeCst = useMemo( const activeCst = useMemo(
() => { () => {
return schema?.items?.find((cst) => cst.id === activeID); return schema?.items?.find((cst) => cst.id === activeID);
}, [schema?.items, activeID]); }, [schema?.items, activeID]);
const { isModified, setIsModified } = useModificationPrompt();
const [editMode, setEditMode] = useState(EditMode.TEXT); const [editMode, setEditMode] = useState(EditMode.TEXT);
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
@ -54,23 +56,24 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
return; return;
} }
setIsModified( setIsModified(
activeCst.term.raw !== term || activeCst.term_raw !== term ||
activeCst.definition.text.raw !== textDefinition || activeCst.definition_raw !== textDefinition ||
activeCst.convention !== convention || activeCst.convention !== convention ||
activeCst.definition.formal !== expression activeCst.definition_formal !== expression
); );
}, [activeCst, activeCst?.term, activeCst?.definition.formal, return () => setIsModified(false);
activeCst?.definition.text.raw, activeCst?.convention, }, [activeCst, activeCst?.term_raw, activeCst?.definition_formal,
activeCst?.definition_raw, activeCst?.convention,
term, textDefinition, expression, convention, setIsModified]); term, textDefinition, expression, convention, setIsModified]);
useLayoutEffect( useLayoutEffect(
() => { () => {
if (activeCst) { if (activeCst) {
setAlias(activeCst.alias); setAlias(activeCst.alias);
setConvention(activeCst.convention ?? ''); setConvention(activeCst.convention || '');
setTerm(activeCst.term?.raw ?? ''); setTerm(activeCst.term_raw || '');
setTextDefinition(activeCst.definition?.text?.raw ?? ''); setTextDefinition(activeCst.definition_raw || '');
setExpression(activeCst.definition?.formal ?? ''); setExpression(activeCst.definition_formal || '');
setTypification(activeCst ? getCstTypificationLabel(activeCst) : 'N/A'); setTypification(activeCst ? getCstTypificationLabel(activeCst) : 'N/A');
} }
}, [activeCst, onOpenEdit, schema]); }, [activeCst, onOpenEdit, schema]);
@ -106,7 +109,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
} }
const data: ICstCreateData = { const data: ICstCreateData = {
insert_after: activeID, insert_after: activeID,
cst_type: activeCst?.cstType ?? CstType.BASE, cst_type: activeCst?.cst_type ?? CstType.BASE,
alias: '', alias: '',
term_raw: '', term_raw: '',
definition_formal: '', definition_formal: '',
@ -123,7 +126,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
const data: ICstRenameData = { const data: ICstRenameData = {
id: activeID, id: activeID,
alias: activeCst?.alias, alias: activeCst?.alias,
cst_type: activeCst.cstType cst_type: activeCst.cst_type
}; };
onRenameCst(data); onRenameCst(data);
} }
@ -181,8 +184,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение' placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение'
rows={2} rows={2}
value={term} value={term}
initialValue={activeCst?.term.raw ?? ''} initialValue={activeCst?.term_raw ?? ''}
resolved={activeCst?.term.resolved ?? ''} resolved={activeCst?.term_resolved ?? ''}
disabled={!isEnabled} disabled={!isEnabled}
spellCheck spellCheck
onChange={event => setTerm(event.target.value)} onChange={event => setTerm(event.target.value)}
@ -209,8 +212,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
placeholder='Лингвистическая интерпретация формального выражения' placeholder='Лингвистическая интерпретация формального выражения'
rows={4} rows={4}
value={textDefinition} value={textDefinition}
initialValue={activeCst?.definition.text.raw ?? ''} initialValue={activeCst?.definition_raw ?? ''}
resolved={activeCst?.definition.text.resolved ?? ''} resolved={activeCst?.definition_resolved ?? ''}
disabled={!isEnabled} disabled={!isEnabled}
spellCheck spellCheck
onChange={event => setTextDefinition(event.target.value)} onChange={event => setTextDefinition(event.target.value)}

View File

@ -215,7 +215,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
{ {
name: 'Термин', name: 'Термин',
id: 'term', id: 'term',
selector: (cst: IConstituenta) => cst.term?.resolved ?? cst.term?.raw ?? '', selector: (cst: IConstituenta) => cst.term_resolved || cst.term_raw || '',
width: '350px', width: '350px',
minWidth: '150px', minWidth: '150px',
maxWidth: '350px', maxWidth: '350px',
@ -225,7 +225,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
{ {
name: 'Формальное определение', name: 'Формальное определение',
id: 'expression', id: 'expression',
selector: (cst: IConstituenta) => cst.definition?.formal ?? '', selector: (cst: IConstituenta) => cst.definition_formal || '',
minWidth: '300px', minWidth: '300px',
maxWidth: '500px', maxWidth: '500px',
grow: 2, grow: 2,
@ -237,7 +237,7 @@ function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps)
id: 'definition', id: 'definition',
cell: (cst: IConstituenta) => ( cell: (cst: IConstituenta) => (
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12 }}>
{cst.definition?.text.resolved ?? cst.definition?.text.raw ?? ''} {cst.definition_resolved || cst.definition_raw || ''}
</div> </div>
), ),
minWidth: '200px', minWidth: '200px',

View File

@ -225,6 +225,12 @@ function EditorRSExpression({
onShowAST={ast => onShowAST(value, ast)} onShowAST={ast => onShowAST(value, ast)}
onShowError={onShowError} onShowError={onShowError}
/>} />}
{ !loading && !parseData &&
<input
disabled={true}
className='w-full h-full px-2 align-middle select-none clr-app'
placeholder='Результаты проверки выражения'
/>}
</div>} </div>}
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useState } from 'react'; import { Dispatch, SetStateAction, useLayoutEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@ -13,7 +13,6 @@ import { CrownIcon, DownloadIcon, DumpBinIcon, HelpIcon, SaveIcon, ShareIcon } f
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { useUsers } from '../../context/UsersContext'; import { useUsers } from '../../context/UsersContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { IRSFormCreateData, LibraryItemType } from '../../utils/models'; import { IRSFormCreateData, LibraryItemType } from '../../utils/models';
interface EditorRSFormProps { interface EditorRSFormProps {
@ -21,9 +20,11 @@ interface EditorRSFormProps {
onClaim: () => void onClaim: () => void
onShare: () => void onShare: () => void
onDownload: () => void onDownload: () => void
isModified: boolean
setIsModified: Dispatch<SetStateAction<boolean>>
} }
function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormProps) { function EditorRSForm({ onDestroy, onClaim, onShare, isModified, setIsModified, onDownload }: EditorRSFormProps) {
const intl = useIntl(); const intl = useIntl();
const { getUserLabel } = useUsers(); const { getUserLabel } = useUsers();
const { const {
@ -38,8 +39,6 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
const [common, setCommon] = useState(false); const [common, setCommon] = useState(false);
const [canonical, setCanonical] = useState(false); const [canonical, setCanonical] = useState(false);
const { isModified, setIsModified } = useModificationPrompt();
useLayoutEffect(() => { useLayoutEffect(() => {
if (!schema) { if (!schema) {
setIsModified(false); setIsModified(false);
@ -52,6 +51,7 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
schema.is_common !== common || schema.is_common !== common ||
schema.is_canonical !== canonical schema.is_canonical !== canonical
); );
return () => setIsModified(false);
}, [schema, schema?.title, schema?.alias, schema?.comment, }, [schema, schema?.title, schema?.alias, schema?.comment,
schema?.is_common, schema?.is_canonical, schema?.is_common, schema?.is_canonical,
title, alias, comment, common, canonical, setIsModified]); title, alias, comment, common, canonical, setIsModified]);
@ -138,10 +138,10 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
disabled={!isEditable} disabled={!isEditable}
onChange={event => setCommon(event.target.checked)} onChange={event => setCommon(event.target.checked)}
/> />
<Checkbox id='canonical' label='Неизменяемая схема' <Checkbox id='canonical' label='Неизменная схема'
widthClass='w-fit' widthClass='w-fit'
value={canonical} value={canonical}
tooltip='Только администраторы могут присваивать схемам библиотечный статус' tooltip='Только администраторы могут присваивать схемам неизменный статус'
disabled={!isEditable || !isForceAdmin} disabled={!isEditable || !isForceAdmin}
onChange={event => setCanonical(event.target.checked)} onChange={event => setCanonical(event.target.checked)}
/> />

View File

@ -31,7 +31,7 @@ const TREE_SIZE_MILESTONE = 50;
function getCstNodeColor(cst: IConstituenta, coloringScheme: ColoringScheme, colors: IColorTheme): string { function getCstNodeColor(cst: IConstituenta, coloringScheme: ColoringScheme, colors: IColorTheme): string {
if (coloringScheme === 'type') { if (coloringScheme === 'type') {
return getCstClassColor(cst.cstClass, colors); return getCstClassColor(cst.cst_class, colors);
} }
if (coloringScheme === 'status') { if (coloringScheme === 'status') {
return getCstStatusColor(cst.status, colors); return getCstStatusColor(cst.status, colors);
@ -125,14 +125,14 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
} }
if (noTemplates) { if (noTemplates) {
schema.items.forEach(cst => { schema.items.forEach(cst => {
if (cst.isTemplate) { if (cst.is_template) {
graph.foldNode(cst.id); graph.foldNode(cst.id);
} }
}); });
} }
if (allowedTypes.length < Object.values(CstType).length) { if (allowedTypes.length < Object.values(CstType).length) {
schema.items.forEach(cst => { schema.items.forEach(cst => {
if (!allowedTypes.includes(cst.cstType)) { if (!allowedTypes.includes(cst.cst_type)) {
graph.foldNode(cst.id); graph.foldNode(cst.id);
} }
}); });
@ -173,7 +173,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
result.push({ result.push({
id: String(node.id), id: String(node.id),
fill: getCstNodeColor(cst, coloringScheme, colors), 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( const canvasHeight = useMemo(
() => { () => {
return !noNavigation ? return !noNavigation ?
'calc(100vh - 10.1rem)' 'calc(100vh - 9.8rem - 4px)'
: 'calc(100vh - 2.1rem)'; : 'calc(100vh - 3rem - 4px)';
}, [noNavigation]); }, [noNavigation]);
const dismissedStyle = useCallback( const dismissedStyle = useCallback(
@ -360,7 +360,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
<div className='relative'> <div className='relative'>
<InfoConstituenta <InfoConstituenta
data={hoverCst} data={hoverCst}
className='absolute top-0 left-0 z-50 w-[25rem] min-h-[11rem] overflow-y-auto border h-fit clr-app px-3' className='absolute top-[2.2rem] left-[2.6rem] z-50 w-[25rem] min-h-[11rem] overflow-y-auto border h-fit clr-app px-3'
/> />
</div>} </div>}
@ -460,7 +460,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
</div> </div>
</div> </div>
</div> </div>
<div className='w-full h-full overflow-auto'> <div className='w-full h-full overflow-auto border'>
<div <div
className='relative' className='relative'
style={{width: canvasWidth, height: canvasHeight, borderBottomWidth: noNavigation ? '1px': ''}} style={{width: canvasWidth, height: canvasHeight, borderBottomWidth: noNavigation ? '1px': ''}}

View File

@ -10,8 +10,9 @@ import { Loader } from '../../components/Common/Loader';
import { useLibrary } from '../../context/LibraryContext'; import { useLibrary } from '../../context/LibraryContext';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext'; import { useConceptTheme } from '../../context/ThemeContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { prefixes, TIMEOUT_UI_REFRESH } from '../../utils/constants'; import { prefixes, TIMEOUT_UI_REFRESH } from '../../utils/constants';
import { ICstCreateData, ICstRenameData, LibraryFilterStrategy, SyntaxTree } from '../../utils/models'; import { ICstCreateData, ICstRenameData, SyntaxTree } from '../../utils/models';
import { createAliasFor } from '../../utils/staticUI'; import { createAliasFor } from '../../utils/staticUI';
import DlgCloneRSForm from './DlgCloneRSForm'; import DlgCloneRSForm from './DlgCloneRSForm';
import DlgCreateCst from './DlgCreateCst'; import DlgCreateCst from './DlgCreateCst';
@ -43,6 +44,8 @@ function RSTabs() {
const { destroySchema } = useLibrary(); const { destroySchema } = useLibrary();
const { setNoFooter } = useConceptTheme(); const { setNoFooter } = useConceptTheme();
const { isModified, setIsModified } = useModificationPrompt();
const [activeTab, setActiveTab] = useState<RSTabID>(RSTabID.CARD); const [activeTab, setActiveTab] = useState<RSTabID>(RSTabID.CARD);
const [activeID, setActiveID] = useState<number | undefined>(undefined); const [activeID, setActiveID] = useState<number | undefined>(undefined);
@ -204,7 +207,7 @@ function RSTabs() {
} }
destroySchema(schema.id, () => { destroySchema(schema.id, () => {
toast.success('Схема удалена'); toast.success('Схема удалена');
navigate(`/library?filter=${LibraryFilterStrategy.PERSONAL}`); navigate('/library');
}); });
}, [schema, destroySchema, navigate]); }, [schema, destroySchema, navigate]);
@ -226,6 +229,11 @@ function RSTabs() {
const onDownloadSchema = useCallback( const onDownloadSchema = useCallback(
() => { () => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
const fileName = (schema?.alias ?? 'Schema') + '.trs'; const fileName = (schema?.alias ?? 'Schema') + '.trs';
download( download(
(data) => { (data) => {
@ -235,7 +243,17 @@ function RSTabs() {
console.error(error); 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( const handleToggleSubscribe = useCallback(
() => { () => {
@ -302,7 +320,7 @@ function RSTabs() {
onClaim={onClaimSchema} onClaim={onClaimSchema}
onShare={onShareSchema} onShare={onShareSchema}
onToggleSubscribe={handleToggleSubscribe} onToggleSubscribe={handleToggleSubscribe}
showCloneDialog={() => setShowClone(true)} showCloneDialog={handleShowClone}
showUploadDialog={() => setShowUpload(true)} showUploadDialog={() => setShowUpload(true)}
/> />
<ConceptTab className='border-r-2 min-w-[7.8rem]'>Паспорт схемы</ConceptTab> <ConceptTab className='border-r-2 min-w-[7.8rem]'>Паспорт схемы</ConceptTab>
@ -316,6 +334,8 @@ function RSTabs() {
<TabPanel className='flex w-full gap-4'> <TabPanel className='flex w-full gap-4'>
<EditorRSForm <EditorRSForm
isModified={isModified}
setIsModified={setIsModified}
onDownload={onDownloadSchema} onDownload={onDownloadSchema}
onDestroy={onDestroySchema} onDestroy={onDestroySchema}
onClaim={onClaimSchema} onClaim={onClaimSchema}
@ -334,6 +354,8 @@ function RSTabs() {
<TabPanel> <TabPanel>
<EditorConstituenta <EditorConstituenta
isModified={isModified}
setIsModified={setIsModified}
activeID={activeID} activeID={activeID}
onOpenEdit={onOpenCst} onOpenEdit={onOpenCst}
onShowAST={onShowAST} onShowAST={onShowAST}

View File

@ -1,9 +1,9 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Button from '../../components/Common/Button'; import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox';
import Dropdown from '../../components/Common/Dropdown'; import Dropdown from '../../components/Common/Dropdown';
import DropdownButton from '../../components/Common/DropdownButton'; import DropdownButton from '../../components/Common/DropdownButton';
import DropdownCheckbox from '../../components/Common/DropdownCheckbox';
import { CloneIcon, CrownIcon, DownloadIcon, DumpBinIcon, EyeIcon, EyeOffIcon, MenuIcon, PenIcon, PlusIcon, ShareIcon, UploadIcon } from '../../components/Icons'; import { CloneIcon, CrownIcon, DownloadIcon, DumpBinIcon, EyeIcon, EyeOffIcon, MenuIcon, PenIcon, PlusIcon, ShareIcon, UploadIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
@ -131,7 +131,7 @@ function RSTabsMenu({
<DropdownButton <DropdownButton
disabled={!user || !isClaimable} disabled={!user || !isClaimable}
onClick={!isOwned ? handleClaimOwner : undefined} onClick={!isOwned ? handleClaimOwner : undefined}
description={!user || !isClaimable ? 'Стать владельцем можно только для общей небиблиотечной схемы' : ''} tooltip={!user || !isClaimable ? 'Стать владельцем можно только для общей изменяемой схемы' : ''}
> >
<div className='inline-flex items-center gap-1 justify-normal'> <div className='inline-flex items-center gap-1 justify-normal'>
<span className={isOwned ? 'text-green' : ''}><CrownIcon size={4} /></span> <span className={isOwned ? 'text-green' : ''}><CrownIcon size={4} /></span>
@ -142,17 +142,18 @@ function RSTabsMenu({
</div> </div>
</DropdownButton> </DropdownButton>
{(isOwned || user?.is_staff) && {(isOwned || user?.is_staff) &&
<DropdownButton onClick={toggleReadonly}> <DropdownCheckbox
<Checkbox
value={isReadonly} value={isReadonly}
onChange={toggleReadonly}
label='Я — читатель!' label='Я — читатель!'
tooltip='Режим чтения' tooltip='Режим чтения'
/> />}
</DropdownButton>}
{user?.is_staff && {user?.is_staff &&
<DropdownButton onClick={toggleForceAdmin}> <DropdownCheckbox
<Checkbox value={isForceAdmin} label='режим администратора'/> value={isForceAdmin}
</DropdownButton>} onChange={toggleForceAdmin}
label='режим администратора'
/>}
</Dropdown>} </Dropdown>}
</div> </div>
<div> <div>

View File

@ -49,7 +49,16 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }:
const diff = Array.from(aliases).filter(name => !names.includes(name)); const diff = Array.from(aliases).filter(name => !names.includes(name));
if (diff.length > 0) { if (diff.length > 0) {
diff.forEach( 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) { } else if (!activeID) {
filtered = schema.items filtered = schema.items
@ -133,7 +142,7 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }:
{ {
name: 'Выражение', name: 'Выражение',
id: 'expression', id: 'expression',
selector: (cst: IConstituenta) => cst.definition?.formal ?? '', selector: (cst: IConstituenta) => cst.definition_formal || '',
minWidth: '200px', minWidth: '200px',
hide: 1600, hide: 1600,
grow: 2, grow: 2,

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import Button from '../components/Common/Button';
import Form from '../components/Common/Form'; import Form from '../components/Common/Form';
import SubmitButton from '../components/Common/SubmitButton'; import SubmitButton from '../components/Common/SubmitButton';
import TextInput from '../components/Common/TextInput'; import TextInput from '../components/Common/TextInput';
@ -10,6 +11,7 @@ import { useAuth } from '../context/AuthContext';
import { type IUserSignupData } from '../utils/models'; import { type IUserSignupData } from '../utils/models';
function RegisterPage() { function RegisterPage() {
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, signup, loading, error, setError } = useAuth(); const { user, signup, loading, error, setError } = useAuth();
@ -24,6 +26,14 @@ function RegisterPage() {
setError(undefined); setError(undefined);
}, [username, email, password, password2, setError]); }, [username, email, password, password2, setError]);
function handleCancel() {
if (location.key !== "default") {
navigate(-1);
} else {
navigate('/library');
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!loading) {
@ -48,7 +58,7 @@ function RegisterPage() {
<b>{`Вы вошли в систему как ${user.username}. Если хотите зарегистрировать нового пользователя, выйдите из системы (меню в правом верхнем углу экрана)`}</b>} <b>{`Вы вошли в систему как ${user.username}. Если хотите зарегистрировать нового пользователя, выйдите из системы (меню в правом верхнем углу экрана)`}</b>}
{ !user && { !user &&
<Form <Form
title='Регистрация пользователя' title='Регистрация'
onSubmit={handleSubmit} onSubmit={handleSubmit}
widthClass='w-[24rem]' widthClass='w-[24rem]'
> >
@ -89,8 +99,17 @@ function RegisterPage() {
onChange={event => setLastName(event.target.value)} onChange={event => setLastName(event.target.value)}
/> />
<div className='flex items-center justify-center w-full my-4'> <div className='flex items-center justify-center w-full gap-4 my-4'>
<SubmitButton text='Регистрировать' loading={loading}/> <SubmitButton
text='Регистрировать'
loading={loading}
widthClass='min-w-[10rem]'
/>
<Button
text='Отмена'
onClick={() => handleCancel()}
widthClass='min-w-[10rem]'
/>
</div> </div>
{ error && <BackendError error={error} />} { error && <BackendError error={error} />}
</Form> </Form>

View File

@ -1,16 +1,7 @@
// Constants // Constants
const prod = { export const config = {
backend: 'https://portal.acconcept.ru:8082', backend: import.meta.env.VITE_PORTAL_BACKEND as string
// backend: 'https://dev.concept.ru:8000',
// backend: 'https://localhost:8000',
// backend: 'https://api.portal.concept.ru',
}; };
const dev = {
backend: 'http://localhost:8000',
};
export const config = process.env.NODE_ENV === 'production' ? prod : dev;
export const TIMEOUT_UI_REFRESH = 100; export const TIMEOUT_UI_REFRESH = 100;
export const youtube = { export const youtube = {

View File

@ -148,33 +148,9 @@ export enum CstClass {
TEMPLATE = 'template' TEMPLATE = 'template'
} }
export interface IConstituenta { export interface TermForm {
id: number text: string
alias: string tags: 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 IConstituentaMeta { export interface IConstituentaMeta {
@ -189,6 +165,21 @@ export interface IConstituentaMeta {
definition_resolved: string definition_resolved: string
term_raw: string term_raw: string
term_resolved: 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<IConstituentaMeta, 'id'>{} export interface IConstituentaID extends Pick<IConstituentaMeta, 'id'>{}
@ -431,35 +422,35 @@ export function LoadRSFormData(schema: IRSFormData): IRSForm {
((cst.parse?.status === ParsingStatus.VERIFIED && cst.parse?.valueClass === ValueClass.INVALID) ? 1 : 0) || 0, 0), ((cst.parse?.status === ParsingStatus.VERIFIED && cst.parse?.valueClass === ValueClass.INVALID) ? 1 : 0) || 0, 0),
count_termin: result.items.reduce( 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( 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( count_convention: result.items.reduce(
(sum, cst) => (sum + (cst.convention ? 1 : 0) || 0), 0), (sum, cst) => (sum + (cst.convention ? 1 : 0) || 0), 0),
count_base: result.items.reduce( 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( 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( 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( 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( 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( 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( 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( 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 => { result.items.forEach(cst => {
cst.status = inferStatus(cst.parse.status, cst.parse.valueClass); cst.status = inferStatus(cst.parse.status, cst.parse.valueClass);
cst.isTemplate = inferTemplate(cst.definition.formal); cst.is_template = inferTemplate(cst.definition_formal);
cst.cstClass = inferClass(cst.cstType, cst.isTemplate); cst.cst_class = inferClass(cst.cst_type, cst.is_template);
result.graph.addNode(cst.id); result.graph.addNode(cst.id);
const dependencies = extractGlobals(cst.definition.formal); const dependencies = extractGlobals(cst.definition_formal);
dependencies.forEach(value => { dependencies.forEach(value => {
const source = schema.items.find(cst => cst.alias === value) const source = schema.items.find(cst => cst.alias === value)
if (source) { if (source) {
@ -476,15 +467,15 @@ export function matchConstituenta(query: string, target: IConstituenta, mode: Cs
return true; return true;
} }
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TERM) && if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TERM) &&
target.term.resolved.match(query)) { target.term_resolved.match(query)) {
return true; return true;
} }
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.EXPR) && if ((mode === CstMatchMode.ALL || mode === CstMatchMode.EXPR) &&
target.definition.formal.match(query)) { target.definition_formal.match(query)) {
return true; return true;
} }
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TEXT)) { 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; return false;
} }

View File

@ -16,18 +16,18 @@ export interface IDescriptor {
} }
export function getCstDescription(cst: IConstituenta): string { export function getCstDescription(cst: IConstituenta): string {
if (cst.cstType === CstType.STRUCTURED) { if (cst.cst_type === CstType.STRUCTURED) {
return ( return (
cst.term.resolved || cst.term.raw || cst.term_resolved || cst.term_raw ||
cst.definition.text.resolved || cst.definition.text.raw || cst.definition_resolved || cst.definition_raw ||
cst.convention || cst.convention ||
cst.definition.formal cst.definition_formal
); );
} else { } else {
return ( return (
cst.term.resolved || cst.term.raw || cst.term_resolved || cst.term_raw ||
cst.definition.text.resolved || cst.definition.text.raw || cst.definition_resolved || cst.definition_raw ||
cst.definition.formal || cst.definition_formal ||
cst.convention cst.convention
); );
} }
@ -51,7 +51,7 @@ export function getCstTypePrefix(type: CstType) {
} }
export function getCstExpressionPrefix(cst: IConstituenta): string { 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 { export function getRSButtonData(id: TokenID): IDescriptor {
@ -424,7 +424,7 @@ export function createAliasFor(type: CstType, schema: IRSForm): string {
return `${prefix}1`; return `${prefix}1`;
} }
const index = schema.items.reduce((prev, cst, index) => { const index = schema.items.reduce((prev, cst, index) => {
if (cst.cstType !== type) { if (cst.cst_type !== type) {
return prev; return prev;
} }
index = Number(cst.alias.slice(1 - cst.alias.length)) + 1; 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}`; 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 { return {
id: id, id: id,
order: -1,
schema: schema,
alias: alias, alias: alias,
convention: comment, convention: comment,
cstType: type, cst_type: type,
term: { term_raw: '',
raw: '', term_resolved: '',
resolved: '', term_forms: [],
forms: [] definition_formal: '',
}, definition_raw: '',
definition: { definition_resolved: '',
formal: '',
text: {
raw: '',
resolved: ''
}
},
status: ExpressionStatus.INCORRECT, status: ExpressionStatus.INCORRECT,
isTemplate: false, is_template: false,
cstClass: CstClass.DERIVED, cst_class: CstClass.DERIVED,
parse: { parse: {
status: ParsingStatus.INCORRECT, status: ParsingStatus.INCORRECT,
valueClass: ValueClass.INVALID, valueClass: ValueClass.INVALID,
@ -628,6 +624,7 @@ export function getNodeLabel(node: ISyntaxTreeNode): string {
case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION' case TokenID.NT_ENUM_DECL: return 'ENUM_DECLARATION'
case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION' case TokenID.NT_TUPLE_DECL: return 'TUPLE_DECLARATION'
case TokenID.PUNC_DEFINE: return 'DEFINITION' case TokenID.PUNC_DEFINE: return 'DEFINITION'
case TokenID.PUNC_STRUCT: return 'STRUCTURE_DEFITION'
case TokenID.NT_ARG_DECL: return 'ARG' case TokenID.NT_ARG_DECL: return 'ARG'
case TokenID.NT_FUNC_CALL: return 'CALL' case TokenID.NT_FUNC_CALL: return 'CALL'

View File

@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import { dependencies } from './package.json' import { dependencies } from './package.json'
@ -14,10 +14,16 @@ function renderChunks(deps: Record<string, string>) {
} }
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ 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()], plugins: [react()],
server: { server: {
port: 3000 port: Number(process.env.VITE_PORTAL_FRONT_PORT),
// NOTE: https is not used for dev builds currently
https: enableHttps,
}, },
build: { build: {
chunkSizeWarningLimit: 4000, // KB chunkSizeWarningLimit: 4000, // KB
@ -25,9 +31,11 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
// Load chunks for dependencies separately
...renderChunks(dependencies), ...renderChunks(dependencies),
}, },
}, },
}, },
} }
}) });
});

View File

@ -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

View File

@ -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

View File

@ -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

17
scripts/dev/RunLint.ps1 Normal file
View File

@ -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

View File

@ -1,22 +1,25 @@
# Run local server # Run local server
Param( Param(
[switch] $freshStart [switch] $freshStart
) )
$pyExec = "$PSScriptRoot\backend\venv\Scripts\python.exe" $backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend"
$djangoSrc = "$PSScriptRoot\backend\manage.py" $frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\frontend"
$pyExec = "$backend\venv\Scripts\python.exe"
$djangoSrc = "$backend\manage.py"
$initialData = "fixtures/InitialData.json" $initialData = "fixtures/InitialData.json"
function RunServer() { function RunServer() {
RunBackend BackendRun
RunFrontend FrontendRun
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
Start-Process "http://localhost:8000/" Start-Process "http://localhost:8000/"
Start-Process "http://localhost:3000/" Start-Process "http://localhost:3000/"
} }
function RunBackend() { function BackendRun() {
Set-Location $PSScriptRoot\backend Set-Location $backend
if ($freshStart) { if ($freshStart) {
FlushData FlushData
DoMigrations DoMigrations
@ -30,15 +33,15 @@ function RunBackend() {
Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'django'; & $pyExec $djangoSrc runserver }" Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'django'; & $pyExec $djangoSrc runserver }"
} }
function RunFrontend() { function FrontendRun() {
Set-Location $PSScriptRoot\frontend Set-Location $frontend
& npm install & npm install
Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'react'; & npm run dev }" Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'react'; & npm run dev }"
} }
function FlushData { function FlushData {
& $pyExec $djangoSrc flush --noinput & $pyExec $djangoSrc flush --noinput
$dbPath = "$PSScriptRoot\backend\db.sqlite3" $dbPath = "$backend\db.sqlite3"
if (Test-Path -Path $dbPath -PathType Leaf) { if (Test-Path -Path $dbPath -PathType Leaf) {
Remove-Item $dbPath Remove-Item $dbPath
} }

25
scripts/dev/RunTests.ps1 Normal file
View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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