# 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",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1",
"script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": []
},
{
"name": "Lint",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunLint.ps1",
"script": "${workspaceFolder}/scripts/dev/RunLint.ps1",
"args": []
},
{
"name": "Test",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunTests.ps1",
"script": "${workspaceFolder}/scripts/dev/RunTests.ps1",
"args": []
},
{
"name": "BE-Coverage",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunCoverage.ps1",
"script": "${workspaceFolder}/scripts/dev/RunCoverage.ps1",
"args": []
},
{
@ -57,7 +57,7 @@
"name": "Restart",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1",
"script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": ["-freshStart"]
},
{

View File

@ -2,20 +2,11 @@
React + Django based web portal for editing RSForm schemas.
This readme file is used mostly to document project dependencies
# Developer Setup Notes
- Install Python 3.9, NodeJS, VSCode, Docker Desktop
- copy import wheels from ConceptCore to rsconcept\backend\import
- run rsconcept\backend\LocalEnvSetup.ps1
- run 'npm install' in rsconcept\frontend
- use VSCode configs in root folder to start developement
- production: create secrets secrets\db_password.txt and django_key.txt
- production: provide TLS certificate nginx\cert\portal-cert.pem and nginx\cert\portal-key.pem
# Contributing notes
!BEFORE PUSHING INTO MAIN!
- use Test config in VSCode to run tests before pushing commits / requests
- !BEFORE PUSHING INTO MAIN! in rsconcept\frontend run in terminal 'npm run build' and fix all errors
- when making major changes make sure that Docker production is building correctly. run 'docker compose -f docker-compose-prod.yml up'
- cd rsconcept/frontend & npm run build
- docker compose -f docker-compose-prod.yml up
# Frontend stack & Tooling [Vite + React + Typescript]
<details>
@ -62,11 +53,11 @@ This readme file is used mostly to document project dependencies
<details>
<summary>requirements</summary>
<pre>
- tzdata
- django
- djangorestframework
- django-cors-headers
- django-filter
- tzdata
- gunicorn
- coreapi
- psycopg2-binary
@ -96,3 +87,32 @@ This readme file is used mostly to document project dependencies
# DevOps
- Docker compose
- PowerShell
- Certbot
- Docker VSCode extension
# Developer Notes
## Local build (Windows 10+)
- this is main developers build
- Install Python 3.9, NodeJS, VSCode, Docker Desktop
- copy import wheels from ConceptCore to rsconcept/backend/import
- run rsconcept/backend/LocalEnvSetup.ps1
- run 'npm install' in rsconcept/frontend
- use VSCode configs in root folder to start developement
## Developement build
- this build does not use HTTPS and nginx for networking
- backend and frontend debugging is supported
- hmr (hot updates) for frontend
- run via 'docker compose -f "docker-compose-dev.yml" up --build -d'
- populate initial data: rsconcept/PopulateDevData.ps1 dev-portal-backend
## Local production build
- this build is same as production except not using production secrets and working on localhost
- provide TLS certificate (can be self-signed) 'nginx/cert/local-cert.pem' and 'nginx/cert/local-key.pem'
- run via 'docker compose -f "docker-compose-prod-local.yml" up --build -d'
- populate initial data: rsconcept/PopulateDevData.ps1 local-portal-backend
## Production build
- create secrets secrets/db_password.txt and django_key.txt
- provide TLS certificate 'nginx/cert/front-cert.pem' and 'nginx/cert/front-key.pem'
- run via 'docker compose -f "docker-compose-prod.yml" up --build -d'

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
build:
context: ./rsconcept/frontend
args:
BUILD_TYPE: production
expose:
- 3000
command: serve -s /home/node -l 3000
@ -72,6 +74,8 @@ services:
restart: always
build:
context: ./nginx
args:
BUILD_TYPE: production
ports:
- 8000:8000
- 3000:3000

View File

@ -1,5 +1,6 @@
FROM nginx:stable-alpine3.17-slim
ARG BUILD_TYPE=production
# Сopу nginx configuration to the proxy-server
COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./$BUILD_TYPE.conf /etc/nginx/conf.d/default.conf
COPY ./cert/* /etc/ssl/private/

View File

@ -10,7 +10,7 @@ server {
listen 8000 ssl;
ssl_certificate /etc/ssl/private/front-cert.pem;
ssl_certificate_key /etc/ssl/private/front-key.pem;
server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru api.portal.acconcept.ru www.api.portal.acconcept.ru mail.acconcept.ru www.mail.acconcept.ru;
server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru api.portal.acconcept.ru www.api.portal.acconcept.ru;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -30,7 +30,7 @@ server {
listen 3000 ssl;
ssl_certificate /etc/ssl/private/front-cert.pem;
ssl_certificate_key /etc/ssl/private/front-key.pem;
server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru mail.acconcept.ru www.mail.acconcept.ru;
server_name dev.concept.ru www.dev.concept.ru portal.acconcept.ru www.portal.acconcept.ru;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

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
# SECRET_KEY=
ALLOWED_HOSTS=portal.acconcept.ru;dev.concept.ru
CSRF_TRUSTED_ORIGINS=https://dev.concept.ru:3000;https://dev.concept.ru:8000;https://portal.acconcept.ru;https://portal.acconcept.ru:8081;https://portal.acconcept.ru:8082
CORS_ALLOWED_ORIGINS=https://dev.concept.ru:3000;https://portal.acconcept.ru;https://portal.acconcept.ru:8081
@ -16,6 +17,7 @@ DB_NAME=portal-db
DB_USER=portal-admin
DB_HOST=postgresql-db
DB_PORT=5432
# DB_PASSWORD=
# Debug settings

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/static && \
mkdir -p $APP_HOME/media && \
mkdir -p $APP_HOME/backup && \
adduser --system --group app
# Install python dependencies
@ -64,7 +65,8 @@ RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \
chmod +x $APP_HOME/entrypoint.sh && \
chown -R app:app $APP_HOME && \
chmod -R a+rwx $APP_HOME/static && \
chmod -R a+rwx $APP_HOME/media
chmod -R a+rwx $APP_HOME/media && \
chmod -R a+rwx $APP_HOME/backup
RUN

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. '''
import json
from copy import deepcopy
import re
from typing import Iterable, Optional, cast
@ -13,7 +11,6 @@ from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.urls import reverse
import pyconcept
from apps.users.models import User
from cctext import Resolver, Entity, extract_entities
from .graph import Graph
@ -315,6 +312,8 @@ class RSForm:
''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
if position <= 0:
raise ValidationError('Invalid position: should be positive integer')
currentSize = self.constituents().count()
position = max(1, min(position, currentSize + 1))
update_list = Constituenta.objects.only('id', 'order', 'schema').filter(schema=self.item, order__gte=position)
for cst in update_list:
cst.order += 1
@ -326,7 +325,6 @@ class RSForm:
alias=alias,
cst_type=insert_type
)
self.update_order()
self.item.save()
result.refresh_from_db()
return result
@ -343,7 +341,6 @@ class RSForm:
alias=alias,
cst_type=insert_type
)
self.update_order()
self.item.save()
result.refresh_from_db()
return result
@ -369,7 +366,6 @@ class RSForm:
count_moved += 1
update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['order'])
self.update_order()
self.item.save()
@transaction.atomic
@ -377,7 +373,7 @@ class RSForm:
''' Delete multiple constituents. Do not check if listCst are from this schema '''
for cst in listCst:
cst.delete()
self.update_order()
self._reset_order()
self.resolve_all_text()
self.item.save()
@ -447,23 +443,6 @@ class RSForm:
if modified:
cst.save()
@transaction.atomic
def update_order(self):
''' Update constituents order. '''
checked = PyConceptAdapter(self).basic()
update_list = self.constituents().only('id', 'order')
if len(checked['items']) != update_list.count():
raise ValidationError('Invalid constituents count')
order = 1
for cst in checked['items']:
cst_id = cst['id']
for oldCst in update_list:
if oldCst.pk == cst_id:
oldCst.order = order
order += 1
break
Constituenta.objects.bulk_update(update_list, ['order'])
@transaction.atomic
def resolve_all_text(self):
''' Trigger reference resolution for all texts. '''
@ -482,6 +461,15 @@ class RSForm:
cst.definition_resolved = resolved
cst.save()
@transaction.atomic
def _reset_order(self):
order = 1
for cst in self.constituents().only('id', 'order').order_by('order'):
if cst.order != order:
cst.order = order
cst.save()
order += 1
def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta':
if insert_after is not None:
cstafter = Constituenta.objects.get(pk=insert_after)
@ -510,87 +498,3 @@ class RSForm:
if result.contains(alias):
result.add_edge(id_from=alias, id_to=cst.alias)
return result
class PyConceptAdapter:
''' RSForm adapter for interacting with pyconcept module. '''
def __init__(self, instance: RSForm):
self.schema = instance
self.data = self._prepare_request()
self._checked_data: Optional[dict] = None
def basic(self) -> dict:
''' Check RSForm and return check results.
Warning! Does not include texts. '''
self._produce_response()
if self._checked_data is None:
raise ValueError('Invalid data response from pyconcept')
return self._checked_data
def full(self) -> dict:
''' Check RSForm and return check results including initial texts. '''
self._produce_response()
if self._checked_data is None:
raise ValueError('Invalid data response from pyconcept')
return self._complete_rsform_details(self._checked_data)
def _complete_rsform_details(self, data: dict) -> dict:
result = deepcopy(data)
result['id'] = self.schema.item.pk
result['alias'] = self.schema.item.alias
result['title'] = self.schema.item.title
result['comment'] = self.schema.item.comment
result['time_update'] = self.schema.item.time_update
result['time_create'] = self.schema.item.time_create
result['is_common'] = self.schema.item.is_common
result['is_canonical'] = self.schema.item.is_canonical
result['owner'] = (self.schema.item.owner.pk if self.schema.item.owner is not None else None)
for cst_data in result['items']:
cst = Constituenta.objects.get(pk=cst_data['id'])
cst_data['convention'] = cst.convention
cst_data['term'] = {
'raw': cst.term_raw,
'resolved': cst.term_resolved,
'forms': cst.term_forms
}
cst_data['definition']['text'] = {
'raw': cst.definition_raw,
'resolved': cst.definition_resolved,
}
result['subscribers'] = [item.pk for item in self.schema.item.subscribers()]
return result
def _prepare_request(self) -> dict:
result: dict = {
'items': []
}
items = self.schema.constituents().order_by('order')
for cst in items:
result['items'].append({
'entityUID': cst.pk,
'cstType': cst.cst_type,
'alias': cst.alias,
'definition': {
'formal': cst.definition_formal
}
})
return result
def _produce_response(self):
if self._checked_data is not None:
return
response = pyconcept.check_schema(json.dumps(self.data))
data = json.loads(response)
self._checked_data = {
'items': []
}
for cst in data['items']:
self._checked_data['items'].append({
'id': cst['entityUID'],
'cstType': cst['cstType'],
'alias': cst['alias'],
'definition': {
'formal': cst['definition']['formal']
},
'parse': cst['parse']
})

View File

@ -1,8 +1,10 @@
''' Serializers for conceptual schema API. '''
import json
from typing import Optional, cast
from rest_framework import serializers
from django.db import transaction
import pyconcept
from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference
from .utils import fix_old_references
@ -31,7 +33,7 @@ class TextSerializer(serializers.Serializer):
class LibraryItemSerializer(serializers.ModelSerializer):
''' Serializer: Library item data. '''
''' Serializer: LibraryItem entry. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
@ -39,6 +41,71 @@ class LibraryItemSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id', 'item_type')
class LibraryItemDetailsSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem detailed data. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
read_only_fields = ('owner', 'id', 'item_type')
def to_representation(self, instance: LibraryItem):
result = super().to_representation(instance)
result['subscribers'] = [item.pk for item in instance.subscribers()]
return result
class PyConceptAdapter:
''' RSForm adapter for interacting with pyconcept module. '''
def __init__(self, instance: RSForm):
self.schema = instance
self.data = self._prepare_request()
self._checked_data: Optional[dict] = None
def parse(self) -> dict:
''' Check RSForm and return check results.
Warning! Does not include texts. '''
self._produce_response()
if self._checked_data is None:
raise ValueError('Invalid data response from pyconcept')
return self._checked_data
def _prepare_request(self) -> dict:
result: dict = {
'items': []
}
items = self.schema.constituents().order_by('order')
for cst in items:
result['items'].append({
'entityUID': cst.pk,
'cstType': cst.cst_type,
'alias': cst.alias,
'definition': {
'formal': cst.definition_formal
}
})
return result
def _produce_response(self):
if self._checked_data is not None:
return
response = pyconcept.check_schema(json.dumps(self.data))
data = json.loads(response)
self._checked_data = {
'items': []
}
for cst in data['items']:
self._checked_data['items'].append({
'id': cst['entityUID'],
'cstType': cst['cstType'],
'alias': cst['alias'],
'definition': {
'formal': cst['definition']['formal']
},
'parse': cst['parse']
})
class RSFormSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm. '''
class Meta:
@ -46,13 +113,30 @@ class RSFormSerializer(serializers.ModelSerializer):
model = RSForm
def to_representation(self, instance: RSForm):
result = LibraryItemSerializer(instance.item).data
result = LibraryItemDetailsSerializer(instance.item).data
result['items'] = []
for cst in instance.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data)
return result
class RSFormParseSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm including parse. '''
class Meta:
''' serializer metadata. '''
model = RSForm
def to_representation(self, instance: RSForm):
result = RSFormSerializer(instance).data
parse = PyConceptAdapter(instance).parse()
for cst_data in result['items']:
cst_data['parse'] = next(
cst['parse'] for cst in parse['items']
if cst['id'] == cst_data['id']
)
return result
class RSFormUploadSerializer(serializers.Serializer):
''' Upload data for RSForm serializer. '''
file = serializers.FileField()
@ -193,7 +277,6 @@ class RSFormTRSSerializer(serializers.Serializer):
if prev_cst.pk not in loaded_ids:
prev_cst.delete()
instance.update_order()
instance.resolve_all_text()
instance.item.save()
return instance

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
# Dev specific
.gitignore
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/*
# ======= Build =======
ARG BUILD_TYPE=production
FROM node-base as builder
WORKDIR /result
COPY ./ ./
COPY ./env/.env.$BUILD_TYPE ./
RUN rm -rf ./env
RUN npm install
ENV NODE_ENV production
RUN npm run build

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';
function handleLabelClick(event: React.MouseEvent<HTMLLabelElement, MouseEvent>): void {
function handleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void {
event.preventDefault();
if (!disabled) {
inputRef.current?.click();
@ -26,7 +26,12 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
}
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}
className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox ${cursor}`}
required={required}
@ -40,7 +45,6 @@ function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full
text={label}
required={required}
htmlFor={id}
onClick={handleLabelClick}
/>}
<svg
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' />
</svg>
</div>
</button>
);
}

View File

@ -7,7 +7,7 @@ interface DropdownProps {
function Dropdown({ children, widthClass = 'w-fit', stretchLeft }: DropdownProps) {
return (
<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}
</div>
</div>

View File

@ -1,16 +1,16 @@
interface NavigationTextItemProps {
description?: string | undefined
interface DropdownButtonProps {
tooltip?: string | undefined
onClick?: () => void
disabled?: boolean
children: React.ReactNode
}
function DropdownButton({ description = '', onClick, disabled, children }: NavigationTextItemProps) {
const behavior = (onClick ? 'cursor-pointer clr-hover' : 'cursor-default') + ' disabled:cursor-not-allowed';
function DropdownButton({ tooltip, onClick, disabled, children }: DropdownButtonProps) {
const behavior = (onClick ? 'cursor-pointer disabled:cursor-not-allowed clr-hover' : 'cursor-default');
return (
<button
disabled={disabled}
title={description}
title={tooltip}
onClick={onClick}
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>
<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>

View File

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

View File

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

View File

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

View File

@ -6,24 +6,23 @@ import { getCstTypificationLabel } from '../../utils/staticUI';
function createTooltipFor(cst: IConstituenta) {
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';
const alias = document.createElement('h1');
alias.className = 'text-sm text-left';
alias.textContent = `${cst.alias}: ${getCstTypificationLabel(cst)}`;
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('p');
alias.innerHTML = `<b>${cst.alias}:</b> ${getCstTypificationLabel(cst)}`;
dom.appendChild(alias);
if (cst.term.resolved) {
if (cst.term_resolved) {
const term = document.createElement('p');
term.innerHTML = `<b>Термин:</b> ${cst.term.resolved}`;
term.innerHTML = `<b>Термин:</b> ${cst.term_resolved}`;
dom.appendChild(term);
}
if (cst.definition.formal) {
if (cst.definition_formal) {
const expression = document.createElement('p');
expression.innerHTML = `<b>Выражение:</b> ${cst.definition.formal}`;
expression.innerHTML = `<b>Выражение:</b> ${cst.definition_formal}`;
dom.appendChild(expression);
}
if (cst.definition.text.resolved) {
if (cst.definition_resolved) {
const definition = document.createElement('p');
definition.innerHTML = `<b>Определение:</b> ${cst.definition.text.resolved}`;
definition.innerHTML = `<b>Определение:</b> ${cst.definition_resolved}`;
dom.appendChild(definition);
}
if (cst.convention) {

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import BackendError from '../components/BackendError';
import Button from '../components/Common/Button';
import Checkbox from '../components/Common/Checkbox';
import FileInput from '../components/Common/FileInput';
import Form from '../components/Common/Form';
@ -14,6 +15,7 @@ import { useLibrary } from '../context/LibraryContext';
import { IRSFormCreateData, LibraryItemType } from '../utils/models';
function CreateRSFormPage() {
const location = useLocation();
const navigate = useNavigate();
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>) {
event.preventDefault();
if (processing) {
@ -89,10 +99,16 @@ function CreateRSFormPage() {
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
text='Создать схему'
loading={processing}
widthClass='min-w-[10rem]'
/>
<Button
text='Отмена'
onClick={() => handleCancel()}
widthClass='min-w-[10rem]'
/>
</div>
{ error && <BackendError error={error} />}

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { MagnifyingGlassIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import useLocalStorage from '../../hooks/useLocalStorage';
import { ILibraryFilter, LibraryFilterStrategy } from '../../utils/models';
import PickerStrategy from './PickerStrategy';
@ -30,7 +31,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
const { user } = useAuth();
const [query, setQuery] = useState('');
const [strategy, setStrategy] = useState(LibraryFilterStrategy.MANUAL);
const [strategy, setStrategy] = useLocalStorage<LibraryFilterStrategy>('search_strategy', LibraryFilterStrategy.MANUAL);
function handleChangeQuery(event: React.ChangeEvent<HTMLInputElement>) {
const newQuery = event.target.value;
@ -49,11 +50,15 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
useLayoutEffect(() => {
const searchFilter = new URLSearchParams(search).get('filter') as LibraryFilterStrategy | null;
if (searchFilter === null) {
navigate(`/library?filter=${strategy}`);
return;
}
const inputStrategy = searchFilter && Object.values(LibraryFilterStrategy).includes(searchFilter) ? searchFilter : LibraryFilterStrategy.MANUAL;
setQuery('')
setStrategy(inputStrategy)
setFilter(ApplyStrategy(inputStrategy));
}, [user, search, setQuery, setFilter]);
}, [user, search, setQuery, setFilter, setStrategy, strategy, navigate]);
const handleChangeStrategy = useCallback(
(value: LibraryFilterStrategy) => {
@ -64,7 +69,7 @@ function SearchPanel({ total, filtered, setFilter }: SearchPanelProps) {
}, [strategy, navigate]);
return (
<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]'>
Фильтр
<span className='ml-2'>

View File

@ -10,6 +10,7 @@ import { useAuth } from '../context/AuthContext';
import { IUserLoginData } from '../utils/models';
function LoginPage() {
const location = useLocation();
const navigate = useNavigate();
const search = useLocation().search;
const { user, login, loading, error, setError } = useAuth();
@ -34,7 +35,13 @@ function LoginPage() {
username: username,
password: password
};
login(data, () => navigate('/library'));
login(data, () => {
if (location.key !== "default") {
navigate(-1);
} else {
navigate('/library');
}
});
}
}
@ -44,7 +51,7 @@ function LoginPage() {
? <b>{`Вы вошли в систему как ${user.username}`}</b>
:
<Form
title='Ввод данных пользователя'
title='Вход в Портал'
onSubmit={handleSubmit}
widthClass='w-[24rem]'
>
@ -64,10 +71,10 @@ function LoginPage() {
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
text='Вход'
widthClass='w-[7rem]'
widthClass='w-[12rem]'
loading={loading}
/>
</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 ConceptTooltip from '../../components/Common/ConceptTooltip';
@ -9,7 +9,6 @@ import TextArea from '../../components/Common/TextArea';
import HelpConstituenta from '../../components/Help/HelpConstituenta';
import { DumpBinIcon, HelpIcon, PenIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { CstType, EditMode, ICstCreateData, ICstRenameData, ICstUpdateData, SyntaxTree } from '../../utils/models';
import { getCstTypificationLabel } from '../../utils/staticUI';
import EditorRSExpression from './EditorRSExpression';
@ -25,17 +24,20 @@ interface EditorConstituentaProps {
onCreateCst: (initial: ICstCreateData, skipDialog?: boolean) => void
onRenameCst: (initial: ICstRenameData) => 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 activeCst = useMemo(
() => {
return schema?.items?.find((cst) => cst.id === activeID);
}, [schema?.items, activeID]);
const { isModified, setIsModified } = useModificationPrompt();
const [editMode, setEditMode] = useState(EditMode.TEXT);
const [alias, setAlias] = useState('');
@ -54,23 +56,24 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
return;
}
setIsModified(
activeCst.term.raw !== term ||
activeCst.definition.text.raw !== textDefinition ||
activeCst.term_raw !== term ||
activeCst.definition_raw !== textDefinition ||
activeCst.convention !== convention ||
activeCst.definition.formal !== expression
activeCst.definition_formal !== expression
);
}, [activeCst, activeCst?.term, activeCst?.definition.formal,
activeCst?.definition.text.raw, activeCst?.convention,
return () => setIsModified(false);
}, [activeCst, activeCst?.term_raw, activeCst?.definition_formal,
activeCst?.definition_raw, activeCst?.convention,
term, textDefinition, expression, convention, setIsModified]);
useLayoutEffect(
() => {
if (activeCst) {
setAlias(activeCst.alias);
setConvention(activeCst.convention ?? '');
setTerm(activeCst.term?.raw ?? '');
setTextDefinition(activeCst.definition?.text?.raw ?? '');
setExpression(activeCst.definition?.formal ?? '');
setConvention(activeCst.convention || '');
setTerm(activeCst.term_raw || '');
setTextDefinition(activeCst.definition_raw || '');
setExpression(activeCst.definition_formal || '');
setTypification(activeCst ? getCstTypificationLabel(activeCst) : 'N/A');
}
}, [activeCst, onOpenEdit, schema]);
@ -106,7 +109,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
}
const data: ICstCreateData = {
insert_after: activeID,
cst_type: activeCst?.cstType ?? CstType.BASE,
cst_type: activeCst?.cst_type ?? CstType.BASE,
alias: '',
term_raw: '',
definition_formal: '',
@ -123,7 +126,7 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
const data: ICstRenameData = {
id: activeID,
alias: activeCst?.alias,
cst_type: activeCst.cstType
cst_type: activeCst.cst_type
};
onRenameCst(data);
}
@ -181,8 +184,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение'
rows={2}
value={term}
initialValue={activeCst?.term.raw ?? ''}
resolved={activeCst?.term.resolved ?? ''}
initialValue={activeCst?.term_raw ?? ''}
resolved={activeCst?.term_resolved ?? ''}
disabled={!isEnabled}
spellCheck
onChange={event => setTerm(event.target.value)}
@ -209,8 +212,8 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
placeholder='Лингвистическая интерпретация формального выражения'
rows={4}
value={textDefinition}
initialValue={activeCst?.definition.text.raw ?? ''}
resolved={activeCst?.definition.text.resolved ?? ''}
initialValue={activeCst?.definition_raw ?? ''}
resolved={activeCst?.definition_resolved ?? ''}
disabled={!isEnabled}
spellCheck
onChange={event => setTextDefinition(event.target.value)}

View File

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

View File

@ -225,6 +225,12 @@ function EditorRSExpression({
onShowAST={ast => onShowAST(value, ast)}
onShowError={onShowError}
/>}
{ !loading && !parseData &&
<input
disabled={true}
className='w-full h-full px-2 align-middle select-none clr-app'
placeholder='Результаты проверки выражения'
/>}
</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 { toast } from 'react-toastify';
@ -13,7 +13,6 @@ import { CrownIcon, DownloadIcon, DumpBinIcon, HelpIcon, SaveIcon, ShareIcon } f
import { useAuth } from '../../context/AuthContext';
import { useRSForm } from '../../context/RSFormContext';
import { useUsers } from '../../context/UsersContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { IRSFormCreateData, LibraryItemType } from '../../utils/models';
interface EditorRSFormProps {
@ -21,9 +20,11 @@ interface EditorRSFormProps {
onClaim: () => void
onShare: () => void
onDownload: () => void
isModified: boolean
setIsModified: Dispatch<SetStateAction<boolean>>
}
function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormProps) {
function EditorRSForm({ onDestroy, onClaim, onShare, isModified, setIsModified, onDownload }: EditorRSFormProps) {
const intl = useIntl();
const { getUserLabel } = useUsers();
const {
@ -38,8 +39,6 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
const [common, setCommon] = useState(false);
const [canonical, setCanonical] = useState(false);
const { isModified, setIsModified } = useModificationPrompt();
useLayoutEffect(() => {
if (!schema) {
setIsModified(false);
@ -52,6 +51,7 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
schema.is_common !== common ||
schema.is_canonical !== canonical
);
return () => setIsModified(false);
}, [schema, schema?.title, schema?.alias, schema?.comment,
schema?.is_common, schema?.is_canonical,
title, alias, comment, common, canonical, setIsModified]);
@ -138,10 +138,10 @@ function EditorRSForm({ onDestroy, onClaim, onShare, onDownload }: EditorRSFormP
disabled={!isEditable}
onChange={event => setCommon(event.target.checked)}
/>
<Checkbox id='canonical' label='Неизменяемая схема'
<Checkbox id='canonical' label='Неизменная схема'
widthClass='w-fit'
value={canonical}
tooltip='Только администраторы могут присваивать схемам библиотечный статус'
tooltip='Только администраторы могут присваивать схемам неизменный статус'
disabled={!isEditable || !isForceAdmin}
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 {
if (coloringScheme === 'type') {
return getCstClassColor(cst.cstClass, colors);
return getCstClassColor(cst.cst_class, colors);
}
if (coloringScheme === 'status') {
return getCstStatusColor(cst.status, colors);
@ -125,14 +125,14 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
}
if (noTemplates) {
schema.items.forEach(cst => {
if (cst.isTemplate) {
if (cst.is_template) {
graph.foldNode(cst.id);
}
});
}
if (allowedTypes.length < Object.values(CstType).length) {
schema.items.forEach(cst => {
if (!allowedTypes.includes(cst.cstType)) {
if (!allowedTypes.includes(cst.cst_type)) {
graph.foldNode(cst.id);
}
});
@ -173,7 +173,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
result.push({
id: String(node.id),
fill: getCstNodeColor(cst, coloringScheme, colors),
label: cst.term.resolved && !noTerms ? `${cst.alias}: ${cst.term.resolved}` : cst.alias
label: cst.term_resolved && !noTerms ? `${cst.alias}: ${cst.term_resolved}` : cst.alias
});
}
});
@ -338,8 +338,8 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
const canvasHeight = useMemo(
() => {
return !noNavigation ?
'calc(100vh - 10.1rem)'
: 'calc(100vh - 2.1rem)';
'calc(100vh - 9.8rem - 4px)'
: 'calc(100vh - 3rem - 4px)';
}, [noNavigation]);
const dismissedStyle = useCallback(
@ -360,7 +360,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
<div className='relative'>
<InfoConstituenta
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>}
@ -460,7 +460,7 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
</div>
</div>
</div>
<div className='w-full h-full overflow-auto'>
<div className='w-full h-full overflow-auto border'>
<div
className='relative'
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 { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
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 DlgCloneRSForm from './DlgCloneRSForm';
import DlgCreateCst from './DlgCreateCst';
@ -43,6 +44,8 @@ function RSTabs() {
const { destroySchema } = useLibrary();
const { setNoFooter } = useConceptTheme();
const { isModified, setIsModified } = useModificationPrompt();
const [activeTab, setActiveTab] = useState<RSTabID>(RSTabID.CARD);
const [activeID, setActiveID] = useState<number | undefined>(undefined);
@ -204,7 +207,7 @@ function RSTabs() {
}
destroySchema(schema.id, () => {
toast.success('Схема удалена');
navigate(`/library?filter=${LibraryFilterStrategy.PERSONAL}`);
navigate('/library');
});
}, [schema, destroySchema, navigate]);
@ -226,6 +229,11 @@ function RSTabs() {
const onDownloadSchema = useCallback(
() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
const fileName = (schema?.alias ?? 'Schema') + '.trs';
download(
(data) => {
@ -235,7 +243,17 @@ function RSTabs() {
console.error(error);
}
});
}, [schema?.alias, download]);
}, [schema?.alias, download, isModified]);
const handleShowClone = useCallback(
() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
setShowClone(true);
}, [isModified]);
const handleToggleSubscribe = useCallback(
() => {
@ -302,7 +320,7 @@ function RSTabs() {
onClaim={onClaimSchema}
onShare={onShareSchema}
onToggleSubscribe={handleToggleSubscribe}
showCloneDialog={() => setShowClone(true)}
showCloneDialog={handleShowClone}
showUploadDialog={() => setShowUpload(true)}
/>
<ConceptTab className='border-r-2 min-w-[7.8rem]'>Паспорт схемы</ConceptTab>
@ -316,6 +334,8 @@ function RSTabs() {
<TabPanel className='flex w-full gap-4'>
<EditorRSForm
isModified={isModified}
setIsModified={setIsModified}
onDownload={onDownloadSchema}
onDestroy={onDestroySchema}
onClaim={onClaimSchema}
@ -334,6 +354,8 @@ function RSTabs() {
<TabPanel>
<EditorConstituenta
isModified={isModified}
setIsModified={setIsModified}
activeID={activeID}
onOpenEdit={onOpenCst}
onShowAST={onShowAST}

View File

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

View File

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

View File

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

View File

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

View File

@ -148,33 +148,9 @@ export enum CstClass {
TEMPLATE = 'template'
}
export interface IConstituenta {
id: number
alias: string
cstType: CstType
convention: string
term: {
raw: string
resolved: string
forms: string[]
}
definition: {
formal: string
text: {
raw: string
resolved: string
}
}
cstClass: CstClass
status: ExpressionStatus
isTemplate: boolean
parse: {
status: ParsingStatus
valueClass: ValueClass
typification: string
syntaxTree: string
args: IFunctionArg[]
}
export interface TermForm {
text: string
tags: string
}
export interface IConstituentaMeta {
@ -189,6 +165,21 @@ export interface IConstituentaMeta {
definition_resolved: string
term_raw: string
term_resolved: string
term_forms: TermForm[]
}
export interface IConstituenta
extends IConstituentaMeta {
cst_class: CstClass
status: ExpressionStatus
is_template: boolean
parse: {
status: ParsingStatus
valueClass: ValueClass
typification: string
syntaxTree: string
args: IFunctionArg[]
}
}
export interface IConstituentaID extends Pick<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),
count_termin: result.items.reduce(
(sum, cst) => (sum + (cst.term?.raw ? 1 : 0) || 0), 0),
(sum, cst) => (sum + (cst.term_raw ? 1 : 0) || 0), 0),
count_definition: result.items.reduce(
(sum, cst) => (sum + (cst.definition?.text.raw ? 1 : 0) || 0), 0),
(sum, cst) => (sum + (cst.definition_raw ? 1 : 0) || 0), 0),
count_convention: result.items.reduce(
(sum, cst) => (sum + (cst.convention ? 1 : 0) || 0), 0),
count_base: result.items.reduce(
(sum, cst) => sum + (cst.cstType === CstType.BASE ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.BASE ? 1 : 0), 0),
count_constant: result.items?.reduce(
(sum, cst) => sum + (cst.cstType === CstType.CONSTANT ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.CONSTANT ? 1 : 0), 0),
count_structured: result.items?.reduce(
(sum, cst) => sum + (cst.cstType === CstType.STRUCTURED ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.STRUCTURED ? 1 : 0), 0),
count_axiom: result.items?.reduce(
(sum, cst) => sum + (cst.cstType === CstType.AXIOM ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.AXIOM ? 1 : 0), 0),
count_term: result.items.reduce(
(sum, cst) => sum + (cst.cstType === CstType.TERM ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.TERM ? 1 : 0), 0),
count_function: result.items.reduce(
(sum, cst) => sum + (cst.cstType === CstType.FUNCTION ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.FUNCTION ? 1 : 0), 0),
count_predicate: result.items.reduce(
(sum, cst) => sum + (cst.cstType === CstType.PREDICATE ? 1 : 0), 0),
(sum, cst) => sum + (cst.cst_type === CstType.PREDICATE ? 1 : 0), 0),
count_theorem: result.items.reduce(
(sum, cst) => sum + (cst.cstType === CstType.THEOREM ? 1 : 0), 0)
(sum, cst) => sum + (cst.cst_type === CstType.THEOREM ? 1 : 0), 0)
}
result.items.forEach(cst => {
cst.status = inferStatus(cst.parse.status, cst.parse.valueClass);
cst.isTemplate = inferTemplate(cst.definition.formal);
cst.cstClass = inferClass(cst.cstType, cst.isTemplate);
cst.is_template = inferTemplate(cst.definition_formal);
cst.cst_class = inferClass(cst.cst_type, cst.is_template);
result.graph.addNode(cst.id);
const dependencies = extractGlobals(cst.definition.formal);
const dependencies = extractGlobals(cst.definition_formal);
dependencies.forEach(value => {
const source = schema.items.find(cst => cst.alias === value)
if (source) {
@ -476,15 +467,15 @@ export function matchConstituenta(query: string, target: IConstituenta, mode: Cs
return true;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TERM) &&
target.term.resolved.match(query)) {
target.term_resolved.match(query)) {
return true;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.EXPR) &&
target.definition.formal.match(query)) {
target.definition_formal.match(query)) {
return true;
}
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.TEXT)) {
return (target.definition.text.resolved.match(query) || target.convention.match(query));
return (target.definition_resolved.match(query) || target.convention.match(query));
}
return false;
}

View File

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

View File

@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import { dependencies } from './package.json'
@ -14,10 +14,16 @@ function renderChunks(deps: Record<string, string>) {
}
// 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()],
server: {
port: 3000
port: Number(process.env.VITE_PORTAL_FRONT_PORT),
// NOTE: https is not used for dev builds currently
https: enableHttps,
},
build: {
chunkSizeWarningLimit: 4000, // KB
@ -25,9 +31,11 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
// Load chunks for dependencies separately
...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(
[switch] $freshStart
)
$pyExec = "$PSScriptRoot\backend\venv\Scripts\python.exe"
$djangoSrc = "$PSScriptRoot\backend\manage.py"
$backend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\backend"
$frontend = Resolve-Path -Path "$PSScriptRoot\..\..\rsconcept\frontend"
$pyExec = "$backend\venv\Scripts\python.exe"
$djangoSrc = "$backend\manage.py"
$initialData = "fixtures/InitialData.json"
function RunServer() {
RunBackend
RunFrontend
BackendRun
FrontendRun
Start-Sleep -Seconds 1
Start-Process "http://localhost:8000/"
Start-Process "http://localhost:3000/"
}
function RunBackend() {
Set-Location $PSScriptRoot\backend
function BackendRun() {
Set-Location $backend
if ($freshStart) {
FlushData
DoMigrations
@ -30,15 +33,15 @@ function RunBackend() {
Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'django'; & $pyExec $djangoSrc runserver }"
}
function RunFrontend() {
Set-Location $PSScriptRoot\frontend
function FrontendRun() {
Set-Location $frontend
& npm install
Invoke-Expression "cmd /c start powershell -Command { `$Host.UI.RawUI.WindowTitle = 'react'; & npm run dev }"
}
function FlushData {
& $pyExec $djangoSrc flush --noinput
$dbPath = "$PSScriptRoot\backend\db.sqlite3"
$dbPath = "$backend\db.sqlite3"
if (Test-Path -Path $dbPath -PathType Leaf) {
Remove-Item $dbPath
}

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