Initial commit
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (18.x) (push) Has been cancelled

This commit is contained in:
IRBorisov 2024-06-07 20:17:03 +03:00
commit 2759f10d09
477 changed files with 54204 additions and 0 deletions

69
.dockerignore Normal file
View File

@ -0,0 +1,69 @@
# Docs
README.md
LICENSE
TODO.txt
# Git
.git
.gitignore
# Windows specific
*.ps1
# Environment variables
.env.*
*/.env.*
# Local build/utility folders
**/venv
**/build
# Byte-compiled / optimized / DLL files
**/__pycache__/
*.py[cod]
*$py.class
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
.mypy_cache/
cover/
# Django
rsconcept/frontend/static
rsconcept/frontend/media
*.log
db.sqlite3
db.sqlite3-journal
# React
.DS_*
*.log
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
# Specific items
docker-compose-dev.yml
docker-compose-prod.yml

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

43
.github/workflows/backend.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Backend CI
defaults:
run:
working-directory: rsconcept/backend
on:
push:
branches: [ "main" ]
paths:
- rsconcept/backend/**
- .github/workflows/backend.yml
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-22.04
strategy:
max-parallel: 4
matrix:
python-version: [3.12]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Lint
run: |
pylint project apps
mypy project apps
- name: Run Tests
if: '!cancelled()'
run: |
python manage.py check
python manage.py test

43
.github/workflows/frontend.yml vendored Normal file
View File

@ -0,0 +1,43 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Frontend CI
defaults:
run:
working-directory: rsconcept/frontend
on:
push:
branches: [ "main" ]
paths:
- rsconcept/frontend/**
- .github/workflows/frontend.yml
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache-dependency-path: rsconcept/frontend/package-lock.json
cache: 'npm'
- name: Build
run: |
npm ci
npm run build --if-present
- name: Test
run: |
npm test

67
.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# SECURITY SENSITIVE FILES
secrets/
nginx/cert/*.pem
# External distributions
rsconcept/backend/import/*.whl
rsconcept/backend/static
rsconcept/backend/media
rsconcept/frontend/dist
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
eggs/
.eggs/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
.mypy_cache/
# Django
*.log
db.sqlite3
db.sqlite3-journal
# React
.DS_*
*.log
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
# NextJS
**/.next/
**/out/
# Environments
venv/
/GitExtensions.settings
rsconcept/frontend/public/privacy.pdf

87
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,87 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Run",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": []
},
{
"name": "Lint",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunLint.ps1",
"args": []
},
{
"name": "Test",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunTests.ps1",
"args": []
},
{
"name": "BE-DebugTestFile",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}/rsconcept/backend",
"program": "${workspaceFolder}/rsconcept/backend/manage.py",
"args": ["test", "-k", "${fileBasenameNoExtension}"],
"django": true
},
{
"name": "FE-DebugTestAll",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/rsconcept/frontend/node_modules/.bin/jest",
"args": [
"${fileBasenameNoExtension}",
"--runInBand",
"--watch",
"--coverage=false",
"--no-cache"
],
"cwd": "${workspaceFolder}/rsconcept/frontend",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"sourceMaps": true,
"windows": {
"program": "${workspaceFolder}/rsconcept/frontend/node_modules/jest/bin/jest"
}
},
{
"name": "FE-Debug",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000/library",
"webRoot": "${workspaceFolder}/rsconcept/frontend"
},
{
"name": "BE-Debug",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/rsconcept/backend/manage.py",
"args": ["runserver"],
"django": true
},
{
"name": "BE-Coverage",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunCoverage.ps1",
"args": []
},
{
"name": "Restart",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunServer.ps1",
"args": ["-freshStart"]
}
]
}

191
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,191 @@
{
"search.exclude": {
".mypy_cache/": true,
".pytest_cache/": true
},
"typescript.tsdk": "rsconcept/frontend/node_modules/typescript/lib",
"eslint.workingDirectories": ["rsconcept/frontend"],
"isort.args": [
"--line-length",
"100",
"--multi-line",
"3",
"--project",
"apps"
],
"autopep8.args": [
"--max-line-length",
"120",
"--aggressive",
"--ignore",
"E303"
],
"[python]": {
"editor.defaultFormatter": "ms-python.autopep8",
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true,
"python.analysis.typeCheckingMode": "off",
"python.analysis.ignore": ["**/tests/**", "**/node_modules/**", "**/venv/**"],
"python.analysis.packageIndexDepths": [
{
"name": "django",
"depth": 5
}
],
"colorize.include": [".tsx", ".jsx", ".ts", ".js"],
"colorize.languages": [
"typescript",
"javascript",
"css",
"typescriptreact",
"javascriptreact"
],
"cSpell.words": [
"ablt",
"acconcept",
"accs",
"actv",
"ADJF",
"ADJS",
"ADVB",
"Analyse",
"Backquote",
"BIGPR",
"cctext",
"Certbot",
"CIHT",
"clsx",
"codemirror",
"Constituenta",
"corsheaders",
"csrftoken",
"cstlist",
"csttype",
"datv",
"Debool",
"Decart",
"djangorestframework",
"Downvote",
"EMPTYSET",
"exteor",
"femn",
"filterset",
"forceatlas",
"futr",
"Geologica",
"Grammeme",
"Grammemes",
"GRND",
"impr",
"inan",
"incapsulation",
"indc",
"INFN",
"Infr",
"INTJ",
"Keymap",
"lezer",
"Litr",
"loct",
"moprho",
"multiword",
"mypy",
"nocheck",
"nomn",
"nooverlap",
"NPRO",
"NUMR",
"Opencorpora",
"overscroll",
"passwordreset",
"perfectivity",
"PNCT",
"ponomarev",
"PRCL",
"PRTF",
"PRTS",
"pssv",
"pyconcept",
"Pylance",
"pylint",
"pymorphy",
"Quantor",
"razdel",
"reagraph",
"redef",
"REDOC",
"Reindex",
"rsconcept",
"rsedit",
"rseditor",
"rsform",
"rsforms",
"rsgraph",
"rslang",
"rstemplates",
"setexpr",
"SIDELIST",
"signup",
"Slng",
"SMALLPR",
"Stylesheet",
"symminus",
"tagset",
"tailwindcss",
"tanstack",
"toastify",
"tooltipic",
"tsdoc",
"unknwn",
"Upvote",
"Viewset",
"viewsets",
"wordform",
"Wordforms",
"Биективная",
"биективной",
"Булеан",
"Бурбаки",
"Версионирование",
"Десинглетон",
"доксинг",
"интерпретируемости",
"интерпретируемость",
"компаратив",
"конституент",
"Конституента",
"конституентами",
"конституенте",
"конституенту",
"конституенты",
"Кучкаров",
"Кучкарова",
"неинтерпретируемый",
"неитерируемого",
"пересинтез",
"Родоструктурная",
"родоструктурного",
"Родоструктурное",
"родоструктурной",
"родоструктурном",
"Синглетон",
"твор",
"Терминологизация",
"троллинг",
"Цермелло",
"ЦИВТ",
"Экстеор",
"Экстеора",
"Экстеоре"
],
"cSpell.language": "en,ru",
"cSpell.ignorePaths": ["node_modules/**", "*.json"]
}

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
portal@acconcept.ru.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 CIHT CONCEPT, IRBorisov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
README.md Normal file
View File

@ -0,0 +1,167 @@
<div align="center">
<a href="https://portal.acconcept.ru/" target="_blank">
<img width="650" src="rsconcept/frontend/public/logo_full.svg" />
</a>
</div>
<br />
<br />
[![Backend CI](https://github.com/IRBorisov/ConceptPortal/actions/workflows/backend.yml/badge.svg?branch=main)](https://github.com/IRBorisov/ConceptPortal/actions/workflows/backend.yml)
[![Frontend CI](https://github.com/IRBorisov/ConceptPortal/actions/workflows/frontend.yml/badge.svg?branch=main)](https://github.com/IRBorisov/ConceptPortal/actions/workflows/frontend.yml)
React + Django based web portal for editing RSForm schemas.
This readme file is used mostly to document project dependencies
## ❤️ Contributing notes
- feel free to open issues, discussion topics, contact maintainer directly
- use Test config in VSCode to run tests before pushing commits / requests
- use github actions to setup linter checks and test builds
## ✨ Frontend [Vite + React + Typescript]
<details>
<summary>npm install</summary>
<pre>
- axios
- clsx
- react-icons
- react-router-dom
- react-toastify
- react-loader-spinner
- react-tabs
- react-intl
- react-select
- react-error-boundary
- react-pdf
- react-tooltip
- js-file-download
- use-debounce
- framer-motion
- reagraph
- @tanstack/react-table
- @uiw/react-codemirror
- @uiw/codemirror-themes
- @lezer/lr
</pre>
</details>
<details>
<summary>npm install -D</summary>
<pre>
- tailwindcss
- postcss
- autoprefixer
- eslint-plugin-simple-import-sort
- eslint-plugin-tsdoc
- jest
- ts-jest
- @types/jest
- @lezer/generator
</pre>
</details>
<details>
<summary>VS Code plugins</summary>
<pre>
- ESLint
- Colorize
- Code Spell Checker (eng + rus)
- Backticks
- Svg Preview
- TODO Highlight v2
</pre>
</details>
<details>
<summary>Google fonts</summary>
<pre>
- Fira Code
- Rubik
- Alegreya Sans SC
- Noto Sans Math
</pre>
</details>
## 🗃️ Backend [Django + PostgreSQL/SQLite]
- [ConceptCore](https://github.com/IRBorisov/ConceptCore)
<details>
<summary>requirements</summary>
<pre>
- django
- djangorestframework
- django-cors-headers
- django-filter
- drf-spectacular
- tzdata
- gunicorn
- coreapi
- psycopg2-binary
- cctext
- pyconcept
</pre>
</details>
<details>
<summary>requirements-dev</summary>
<pre>
- coverage
- pylint
- mypy
- djangorestframework-stubs[compatible-mypy]
</pre>
</details>
<details>
<summary>VS Code plugins</summary>
<pre>
- Pylance
- Pylint
- Django
- autopep8
</pre>
</details>
## ⚙️ DevOps
- Docker compose
- PowerShell
- Certbot
- Docker VSCode extension
# Developer Notes
## 🖥️ Local build (Windows 10+)
This is the build for local Development
- Install Python 3.12, NodeJS, VSCode, Docker Desktop
- copy import wheels from ConceptCore to rsconcept/backend/import
- run rsconcept/backend/LocalEnvSetup.ps1
- use VSCode configs in root folder to start development
- use 'npm run prepare' to regenerate frontend parsers (if you change grammar files)
## 🔭 Local docker 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: 'scripts/dev/PopulateDevData.ps1'
## 📦 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'
## 🔥 Production build
This build is deployed on server.
- provide secrets: 'secrets/db_password.txt', 'django_key.txt', 'email_host.txt', 'email_password.txt', 'email_user.txt'
- check if you need to change SSL/TLS and PORT in 'rsconcept\backend\.env.prod'
- setup domain names for application and API in configs: 'frontend\env\.env.production', 'rsconcept\backend\.env.dev', 'nginx\production.conf'
- provide privacy policy document in PDF: 'frontend/public/privacy.pdf'
- use certbot to obtain certificates via 'docker compose -f "docker-compose-prod.yml" run --rm certbot certonly --webroot --webroot-path /var/www/certbot/ -d portal.acconcept.ru api.portal.acconcept.ru'
- run via 'docker compose -f "docker-compose-prod.yml" up --build -d'
- update via 'bash scripts/prod/UpdateProd.sh'

11
SECURITY.md Normal file
View File

@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.0.0 | :white_check_mark: |
## Reporting a Vulnerability
Please report any security vulnerabilities directly to maintainers, do not use public discussions for cricitical security issues.

64
TODO.txt Normal file
View File

@ -0,0 +1,64 @@
!! This is not complete list of TODOs !!
For more specific TODOs see comments in code
[Functionality - PROGRESS]
- Landing page
- Home page (user specific)
- Operational synthesis schema as LibraryItem ?
- Draggable rows in constituents table
- Clickable IDs in RSEditor tooltips
- Library organization, search and exploration. Consider new user experience
- Private projects and permissions. Consider cooperative editing
- Rework access setup: project-based, user-based, enable sharing. Prevent enumerating access to private schemas by default
[Functionality - PENDING]
- User notifications on edit - consider spam prevention and change aggregation
- Static analyzer for RSForm
- Content based search in Library
- User profile: Settings + settings persistency
- Export PDF (Items list, Graph)
- ARIA (accessibility considerations) - for now machine reading not supported
- Internationalization - at least english version. Consider react.intl
- Focus on codemirror editor when label is clicked (need React 19 ref for clean code solution)
[Tech]
- add debounce to some search fields
- duplicate syntax parsing and type info calculations to client. Consider moving backend to Nodejs or embedding c++ lib
- DataTable: fixed percentage columns, especially for SubstituteTable. Rework column sizing mechanics
[Deployment]
- logs collection
- status dashboard for servers
[Security]
- password-reset leaks info of email being used
- improve nginx config. Consider DDOS and other types of attacks on infrastructure
[Research]
Research and consider integration
- django-allauth - consider supporting popular auth providers
- drf-messages
- backend error message unification
https://drf-standardized-errors.readthedocs.io/en/latest/error_response.html
- radix-ui
- shadcn-ui
- Zod
- use-debounce
- react-query
- react-hook-form
- node-based UI

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

@ -0,0 +1,51 @@
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,69 @@
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: no
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: no
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: no
image: postgres:16-alpine
env_file: ./postgresql/.env.prod.local
volumes:
- postgres_volume:/var/lib/postgresql/data
nginx:
container_name: local-portal-router
restart: no
build:
context: ./nginx
dockerfile: Dockerfile.local
args:
BUILD_TYPE: production.local
ports:
- 8001:8001
- 3001:3001
depends_on:
- backend
- frontend
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

110
docker-compose-prod.yml Normal file
View File

@ -0,0 +1,110 @@
name: concept-portal
volumes:
postgres_volume:
name: "portal-data"
django_static_volume:
name: "portal-static"
django_media_volume:
name: "portal-media"
cerbot_www_volume:
name: "portal-certbot-serve"
cerbot_conf_volume:
name: "portal-certbot-config"
networks:
default:
name: concept-api-net
secrets:
django_key:
file: ./secrets/django_key.txt
db_password:
file: ./secrets/db_password.txt
email_host:
file: ./secrets/email_host.txt
email_user:
file: ./secrets/email_user.txt
email_password:
file: ./secrets/email_password.txt
services:
frontend:
container_name: portal-frontend
restart: always
depends_on:
- backend
build:
context: ./rsconcept/frontend
args:
BUILD_TYPE: production
expose:
- 3000
command: serve -s /home/node -l 3000
backend:
container_name: portal-backend
restart: always
depends_on:
- postgresql-db
secrets:
- db_password
- django_key
- email_host
- email_user
- email_password
build:
context: ./rsconcept/backend
env_file: ./rsconcept/backend/.env.prod
environment:
SECRET_KEY: /run/secrets/django_key
DB_PASSWORD: /run/secrets/db_password
EMAIL_HOST: /run/secrets/email_host
EMAIL_HOST_USER: /run/secrets/email_user
EMAIL_HOST_PASSWORD: /run/secrets/email_password
expose:
- 8000
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:8000
postgresql-db:
container_name: portal-db
restart: always
image: postgres:16-alpine
secrets:
- db_password
env_file: ./postgresql/.env.prod
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- postgres_volume:/var/lib/postgresql/data
certbot:
container_name: portal-certbot
restart: no
image: certbot/certbot:latest
volumes:
- cerbot_www_volume:/var/www/certbot/:rw
- cerbot_conf_volume:/etc/letsencrypt/:rw
nginx:
container_name: portal-router
restart: always
build:
context: ./nginx
args:
BUILD_TYPE: production
ports:
- 80:80
- 443:443
depends_on:
- backend
- frontend
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
- cerbot_www_volume:/var/www/certbot/:ro
- cerbot_conf_volume:/etc/nginx/ssl/:ro

5
nginx/Dockerfile Normal file
View File

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

6
nginx/Dockerfile.local Normal file
View File

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

2
nginx/cert/README.txt Normal file
View File

@ -0,0 +1,2 @@
THIS DIRECTORY IS USED ONLY FOR LOCAL CERTIFICATES
USE CERTBOT IN PRODUCTION CONTAINER TO SUPPLY PRODUCTION CERTIFICATES

78
nginx/production.conf Normal file
View File

@ -0,0 +1,78 @@
upstream innerdjango {
server backend:8000;
}
upstream innerreact {
server frontend:3000;
}
server {
listen 80;
listen [::]:80;
server_name api.portal.acconcept.ru www.api.portal.acconcept.ru;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://api.portal.acconcept.ru$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name portal.acconcept.ru www.portal.acconcept.ru;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://portal.acconcept.ru$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/nginx/ssl/live/api.portal.acconcept.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/api.portal.acconcept.ru/privkey.pem;
server_name api.portal.acconcept.ru www.api.portal.acconcept.ru;
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 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/nginx/ssl/live/portal.acconcept.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/portal.acconcept.ru/privkey.pem;
server_name portal.acconcept.ru www.portal.acconcept.ru;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://innerreact;
proxy_redirect default;
}
}

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

39
nginx/starter.conf Normal file
View File

@ -0,0 +1,39 @@
upstream innerdjango {
server backend:8000;
}
upstream innerreact {
server frontend:3000;
}
server {
listen 80;
listen [::]:80;
server_name api.portal.acconcept.ru www.api.portal.acconcept.ru;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://api.portal.acconcept.ru$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name portal.acconcept.ru www.portal.acconcept.ru;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://portal.acconcept.ru$request_uri;
}
}

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

2
postgresql/.env.prod Normal file
View File

@ -0,0 +1,2 @@
POSTGRES_USER=portal-admin
POSTGRES_DB=portal-db

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,36 @@
# Windows specific
*.ps1
# Dev specific
requirements-dev.txt
.gitignore
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Django
static/
media/
*.log
db.sqlite3
db.sqlite3-journal
# Byte-compiled / optimized / DLL files
**/__pycache__/
*.py[cod]
*$py.class

View File

@ -0,0 +1,37 @@
# 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
# Email
EMAIL_HOST=localhost
EMAIL_PORT=1025
EMAIL_HOST_USER=False
EMAIL_HOST_PASSWORD=False
EMAIL_SSL=False
EMAIL_TLS=False
# 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
DJANGO_LOG_LEVEL=DEBUG

View File

@ -0,0 +1,37 @@
# Application settings
# SECRET_KEY=
ALLOWED_HOSTS=portal.acconcept.ru;api.portal.acconcept.ru
CSRF_TRUSTED_ORIGINS=https://portal.acconcept.ru;https://api.portal.acconcept.ru
CORS_ALLOWED_ORIGINS=https://portal.acconcept.ru
CSRF_COOKIE_DOMAIN=.portal.acconcept.ru
# File locations
STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Email
# EMAIL_HOST=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
EMAIL_PORT=465
EMAIL_SSL=True
EMAIL_TLS=False
# 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=
# Debug settings
DEBUG=0
PYTHONDEVMODE=0
PYTHONTRACEMALLOC=0
DJANGO_LOG_LEVEL=DEBUG

View File

@ -0,0 +1,37 @@
# 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
# Email
EMAIL_HOST=localhost
EMAIL_PORT=1025
EMAIL_HOST_USER=False
EMAIL_HOST_PASSWORD=False
EMAIL_SSL=False
EMAIL_TLS=False
# 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
DJANGO_LOG_LEVEL=DEBUG

635
rsconcept/backend/.pylintrc Normal file
View File

@ -0,0 +1,635 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=pyconcept
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=t_.*,.*migrations.*
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=t_.*?py
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
# Discover python modules and packages in the file system subtree.
recursive=no
# Add paths to the list of the source roots. Supports globbing patterns. The
# source root is an absolute path or a path relative to the current working
# directory used to determine a package namespace for modules located under the
# source root.
source-roots=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=too-many-public-methods,
invalid-name,
no-else-break,
no-else-continue,
no-else-return,
no-member,
too-many-ancestors,
too-many-return-statements,
too-many-locals,
too-many-instance-attributes,
too-few-public-methods,
unused-argument,
missing-function-docstring,
attribute-defined-outside-init,
ungrouped-imports,
abstract-method
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
max-line-length=120
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work..
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io

View File

@ -0,0 +1,84 @@
# ==========================================
# ============ Multi-stage build ===========
# ==========================================
FROM ubuntu:jammy as python-base
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -qq && \
apt-get full-upgrade -y && \
apt-get install -y --no-install-recommends \
curl \
gpg-agent \
software-properties-common && \
add-apt-repository -y ppa:deadsnakes/ppa && \
add-apt-repository -y ppa:ubuntu-toolchain-r/test && \
apt-get install -y --no-install-recommends \
python3.12 \
libstdc++6 && \
curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12 && \
python3.12 -m pip install --upgrade pip && \
python3.12 -m pip install wheel && \
apt-get autoclean -y && \
apt-get autoremove -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# ========= Builder ==============
FROM python-base as builder
# Set env variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
COPY ./requirements.txt ./
RUN python3.12 -m pip wheel \
--no-cache-dir --no-deps \
--wheel-dir=/wheels -r requirements.txt
# ======== Application ============
FROM python-base
# Install security updates and system packages
RUN apt-get update -qq && \
apt-get upgrade -y && \
apt-get install -y \
netcat && \
rm -rf /var/lib/apt/lists/*
# Setup the app user
ENV USER_HOME=/home/app
ENV APP_HOME=/home/app/web
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
WORKDIR $APP_HOME
COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/* && \
rm -rf /wheels
# Copy application sources and setup permissions
COPY apps/ ./apps
COPY project/ ./project
COPY fixtures/ ./fixtures
COPY manage.py entrypoint.sh ./
RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.sh && \
chmod +x $APP_HOME/entrypoint.sh && \
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/backup
RUN
USER app
WORKDIR $APP_HOME
ENTRYPOINT ["sh", "entrypoint.sh"]

View File

View File

@ -0,0 +1,69 @@
''' Admin view: RSForms for conceptual schemas. '''
from django.contrib import admin
from . import models
class ConstituentaAdmin(admin.ModelAdmin):
''' Admin model: Constituenta. '''
ordering = ['schema', 'order']
list_display = ['schema', 'alias', 'term_resolved', 'definition_resolved']
search_fields = ['term_resolved', 'definition_resolved']
class LibraryItemAdmin(admin.ModelAdmin):
''' Admin model: LibraryItem. '''
date_hierarchy = 'time_update'
list_display = [
'alias', 'title', 'owner',
'visible', 'read_only', 'access_policy', 'location',
'time_update'
]
list_filter = ['visible', 'read_only', 'access_policy', 'location', 'time_update']
search_fields = ['alias', 'title', 'location']
class LibraryTemplateAdmin(admin.ModelAdmin):
''' Admin model: LibraryTemplate. '''
list_display = ['id', 'alias']
list_select_related = ['lib_source']
def alias(self, template: models.LibraryTemplate):
if template.lib_source:
return template.lib_source.alias
else:
return 'N/A'
class SubscriptionAdmin(admin.ModelAdmin):
''' Admin model: Subscriptions. '''
list_display = ['id', 'item', 'user']
search_fields = [
'item__title', 'item__alias',
'user__username', 'user__first_name', 'user__last_name'
]
class EditorAdmin(admin.ModelAdmin):
''' Admin model: Editors. '''
list_display = ['id', 'item', 'editor']
search_fields = [
'item__title', 'item__alias',
'editor__username', 'editor__first_name', 'editor__last_name'
]
class VersionAdmin(admin.ModelAdmin):
''' Admin model: Versions. '''
list_display = ['id', 'item', 'version', 'description', 'time_create']
search_fields = [
'item__title', 'item__alias'
]
admin.site.register(models.Constituenta, ConstituentaAdmin)
admin.site.register(models.LibraryItem, LibraryItemAdmin)
admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin)
admin.site.register(models.Subscription, SubscriptionAdmin)
admin.site.register(models.Version, VersionAdmin)
admin.site.register(models.Editor, EditorAdmin)

View File

@ -0,0 +1,8 @@
''' Application: RSForms for conceptual schemas. '''
from django.apps import AppConfig
class RsformConfig(AppConfig):
''' Application config. '''
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.rsform'

View File

@ -0,0 +1,142 @@
''' Utility: Graph implementation. '''
import copy
from typing import Generic, Iterable, Optional, TypeVar
ItemType = TypeVar("ItemType")
class Graph(Generic[ItemType]):
''' Directed graph. '''
def __init__(self, graph: Optional[dict[ItemType, list[ItemType]]] = None):
if graph is None:
self.outputs: dict[ItemType, list[ItemType]] = {}
self.inputs: dict[ItemType, list[ItemType]] = {}
else:
self.outputs = graph
self.inputs: dict[ItemType, list[ItemType]] = {id: [] for id in graph.keys()} # type: ignore[no-redef]
for parent in graph.keys():
for child in graph[parent]:
self.inputs[child].append(parent)
def contains(self, node_id: ItemType) -> bool:
''' Check if node is in graph. '''
return node_id in self.outputs
def has_edge(self, src: ItemType, dest: ItemType) -> bool:
''' Check if edge is in graph. '''
return self.contains(src) and dest in self.outputs[src]
def add_node(self, node_id: ItemType):
''' Add node to graph. '''
if not self.contains(node_id):
self.outputs[node_id] = []
self.inputs[node_id] = []
def add_edge(self, src: ItemType, dest: ItemType):
''' Add edge to graph. '''
self.add_node(src)
self.add_node(dest)
if dest not in self.outputs[src]:
self.outputs[src].append(dest)
if src not in self.inputs[dest]:
self.inputs[dest].append(src)
def expand_inputs(self, origin: Iterable[ItemType]) -> list[ItemType]:
''' Expand origin nodes forward through graph edges. '''
result: list[ItemType] = []
marked: set[ItemType] = set(origin)
for node_id in origin:
if self.contains(node_id):
for child_id in self.inputs[node_id]:
if child_id not in marked and child_id not in result:
result.append(child_id)
position: int = 0
while position < len(result):
node_id = result[position]
position += 1
if node_id not in marked:
marked.add(node_id)
for child_id in self.inputs[node_id]:
if child_id not in marked and child_id not in result:
result.append(child_id)
return result
def expand_outputs(self, origin: Iterable[ItemType]) -> list[ItemType]:
''' Expand origin nodes forward through graph edges. '''
result: list[ItemType] = []
marked: set[ItemType] = set(origin)
for node_id in origin:
if self.contains(node_id):
for child_id in self.outputs[node_id]:
if child_id not in marked and child_id not in result:
result.append(child_id)
position: int = 0
while position < len(result):
node_id = result[position]
position += 1
if node_id not in marked:
marked.add(node_id)
for child_id in self.outputs[node_id]:
if child_id not in marked and child_id not in result:
result.append(child_id)
return result
def transitive_closure(self) -> dict[ItemType, list[ItemType]]:
''' Generate transitive closure - list of reachable nodes for each node. '''
result = copy.deepcopy(self.outputs)
order = self.topological_order()
order.reverse()
for node_id in order:
if len(self.inputs[node_id]) == 0:
continue
for parent in self.inputs[node_id]:
result[parent] = result[parent] + [id for id in result[node_id] if not id in result[parent]]
return result
def topological_order(self) -> list[ItemType]:
''' Return nodes in SOME topological order. '''
result: list[ItemType] = []
marked: set[ItemType] = set()
for node_id in self.outputs.keys():
if node_id in marked:
continue
to_visit: list[ItemType] = [node_id]
while len(to_visit) > 0:
node = to_visit[-1]
if node in marked:
if node not in result:
result.append(node)
to_visit.remove(node)
else:
marked.add(node)
if len(self.outputs[node]) <= 0:
continue
for child_id in self.outputs[node]:
if child_id not in marked:
to_visit.append(child_id)
result.reverse()
return result
def sort_stable(self, target: list[ItemType]) -> list[ItemType]:
''' Returns target stable sorted in topological order based on minimal modifications. '''
if len(target) <= 1:
return target
reachable = self.transitive_closure()
test_set: set[ItemType] = set()
result: list[ItemType] = []
for node_id in reversed(target):
need_move = node_id in test_set
test_set = test_set.union(reachable[node_id])
if not need_move:
result.append(node_id)
continue
for (index, parent) in enumerate(result):
if node_id in reachable[parent]:
if parent in reachable[node_id]:
result.append(node_id)
else:
result.insert(index, node_id)
break
result.reverse()
return result

View File

@ -0,0 +1,66 @@
''' Utility: Text messages. '''
# pylint: skip-file
def constituentaNotOwned(title: str):
return f'Конституента не принадлежит схеме: {title}'
def substitutionNotInList():
return 'Отождествляемая конституента отсутствует в списке'
def schemaNotOwned():
return 'Нет доступа к схеме'
def renameTrivial(name: str):
return f'Имя должно отличаться от текущего: {name}'
def substituteTrivial(name: str):
return f'Отождествление конституенты с собой не корректно: {name}'
def substituteDouble(name: str):
return f'Повторное отождествление: {name}'
def aliasTaken(name: str):
return f'Имя уже используется: {name}'
def invalidLocation():
return f'Некорректная строка расположения'
def invalidEnum(value: str):
return f'Неподдерживаемое значение параметра: {value}'
def pyconceptFailure():
return 'Invalid data response from pyconcept'
def typificationInvalidStr():
return 'Invalid typification string'
def libraryTypeUnexpected():
return 'Attempting to use invalid adaptor for non-RSForm item'
def exteorFileVersionNotSupported():
return 'Некорректный формат файла Экстеор. Сохраните файл в новой версии'
def invalidPosition():
return 'Invalid position: should be positive integer'
def constituentaNoStructure():
return 'Указанная конституента не обладает теоретико-множественной типизацией'
def missingFile():
return 'Отсутствует прикрепленный файл'

View File

@ -0,0 +1,72 @@
# Generated by Django 4.2.4 on 2023-08-26 10:09
import apps.rsform.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='LibraryItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_type', models.CharField(choices=[('rsform', 'Rsform'), ('oss', 'Operations Schema')], max_length=50, verbose_name='Тип')),
('title', models.TextField(verbose_name='Название')),
('alias', models.CharField(blank=True, max_length=255, verbose_name='Шифр')),
('comment', models.TextField(blank=True, verbose_name='Комментарий')),
('is_common', models.BooleanField(default=False, verbose_name='Общая')),
('is_canonical', models.BooleanField(default=False, verbose_name='Каноничная')),
('time_create', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('time_update', models.DateTimeField(auto_now=True, verbose_name='Дата изменения')),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Владелец')),
],
options={
'verbose_name': 'Схема',
'verbose_name_plural': 'Схемы',
},
),
migrations.CreateModel(
name='Constituenta',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=-1, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Позиция')),
('alias', models.CharField(default='undefined', max_length=8, verbose_name='Имя')),
('cst_type', models.CharField(choices=[('basic', 'Base'), ('constant', 'Constant'), ('structure', 'Structured'), ('axiom', 'Axiom'), ('term', 'Term'), ('function', 'Function'), ('predicate', 'Predicate'), ('theorem', 'Theorem')], default='basic', max_length=10, verbose_name='Тип')),
('convention', models.TextField(blank=True, default='', verbose_name='Комментарий/Конвенция')),
('term_raw', models.TextField(blank=True, default='', verbose_name='Термин (с отсылками)')),
('term_resolved', models.TextField(blank=True, default='', verbose_name='Термин')),
('term_forms', models.JSONField(default=apps.rsform.models._empty_forms, verbose_name='Словоформы')),
('definition_formal', models.TextField(blank=True, default='', verbose_name='Родоструктурное определение')),
('definition_raw', models.TextField(blank=True, default='', verbose_name='Текстовое определние (с отсылками)')),
('definition_resolved', models.TextField(blank=True, default='', verbose_name='Текстовое определние')),
('schema', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Концептуальная схема')),
],
options={
'verbose_name': 'Конституента',
'verbose_name_plural': 'Конституенты',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Элемент')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Подписки',
'verbose_name_plural': 'Подписка',
'unique_together': {('user', 'item')},
},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.6 on 2023-10-18 16:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('rsform', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='LibraryTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lib_source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Источник')),
],
options={
'verbose_name': 'Шаблон',
'verbose_name_plural': 'Шаблоны',
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2023-12-27 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0002_librarytemplate'),
]
operations = [
migrations.AlterField(
model_name='constituenta',
name='definition_raw',
field=models.TextField(blank=True, default='', verbose_name='Текстовое определение (с отсылками)'),
),
migrations.AlterField(
model_name='constituenta',
name='definition_resolved',
field=models.TextField(blank=True, default='', verbose_name='Текстовое определение'),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.10 on 2024-03-03 10:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('rsform', '0003_alter_constituenta_definition_raw_and_more'),
]
operations = [
migrations.CreateModel(
name='Version',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.CharField(max_length=20, verbose_name='Версия')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('data', models.JSONField(verbose_name='Содержание')),
('time_create', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Схема')),
],
options={
'verbose_name': 'Версии',
'verbose_name_plural': 'Версия',
'unique_together': {('item', 'version')},
},
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.0.6 on 2024-05-20 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('rsform', '0004_version'),
]
operations = [
migrations.AlterModelOptions(
name='subscription',
options={'verbose_name': 'Подписка', 'verbose_name_plural': 'Подписки'},
),
migrations.AlterModelOptions(
name='version',
options={'verbose_name': 'Версия', 'verbose_name_plural': 'Версии'},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.0.6 on 2024-05-20 14:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0005_alter_subscription_options_alter_version_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Editor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time_create', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
('editor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Редактор')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rsform.libraryitem', verbose_name='Схема')),
],
options={
'verbose_name': 'Редактор',
'verbose_name_plural': 'Редакторы',
'unique_together': {('item', 'editor')},
},
),
]

View File

@ -0,0 +1,65 @@
# Hand written migration 20240531
from django.db import migrations, models
from .. import models as m
class Migration(migrations.Migration):
dependencies = [
('rsform', '0006_editor'),
]
def calculate_location(apps, schema_editor):
LibraryItem = apps.get_model('rsform', 'LibraryItem')
db_alias = schema_editor.connection.alias
for item in LibraryItem.objects.using(db_alias).all():
if item.is_canonical:
location = m.LocationHead.LIBRARY
elif item.is_common:
location = m.LocationHead.COMMON
else:
location = m.LocationHead.USER
item.location = location
item.save(update_fields=['location'])
operations = [
migrations.AddField(
model_name='libraryitem',
name='access_policy',
field=models.CharField(
choices=[
('public', 'Public'),
('protected', 'Protected'),
('private', 'Private')
],
default='public',
max_length=500,
verbose_name='Политика доступа'),
),
migrations.AddField(
model_name='libraryitem',
name='location',
field=models.TextField(default='/U', max_length=500, verbose_name='Расположение'),
),
migrations.AddField(
model_name='libraryitem',
name='read_only',
field=models.BooleanField(default=False, verbose_name='Запретить редактирование'),
),
migrations.AddField(
model_name='libraryitem',
name='visible',
field=models.BooleanField(default=True, verbose_name='Отображаемая'),
),
migrations.RunPython(calculate_location, migrations.RunPython.noop), # type: ignore
migrations.RemoveField(
model_name='libraryitem',
name='is_canonical',
),
migrations.RemoveField(
model_name='libraryitem',
name='is_common',
),
]

View File

@ -0,0 +1,137 @@
''' Models: Constituenta. '''
import re
from django.core.validators import MinValueValidator
from django.db.models import (
CASCADE,
CharField,
ForeignKey,
JSONField,
Model,
PositiveIntegerField,
TextChoices,
TextField
)
from django.urls import reverse
from ..utils import apply_pattern
_REF_ENTITY_PATTERN = re.compile(r'@{([^0-9\-].*?)\|.*?}')
_GLOBAL_ID_PATTERN = re.compile(r'([XCSADFPT][0-9]+)') # cspell:disable-line
def _empty_forms():
return []
class CstType(TextChoices):
''' Type of constituenta. '''
BASE = 'basic'
CONSTANT = 'constant'
STRUCTURED = 'structure'
AXIOM = 'axiom'
TERM = 'term'
FUNCTION = 'function'
PREDICATE = 'predicate'
THEOREM = 'theorem'
class Constituenta(Model):
''' Constituenta is the base unit for every conceptual schema. '''
schema: ForeignKey = ForeignKey(
verbose_name='Концептуальная схема',
to='rsform.LibraryItem',
on_delete=CASCADE
)
order: PositiveIntegerField = PositiveIntegerField(
verbose_name='Позиция',
validators=[MinValueValidator(1)],
default=-1,
)
alias: CharField = CharField(
verbose_name='Имя',
max_length=8,
default='undefined'
)
cst_type: CharField = CharField(
verbose_name='Тип',
max_length=10,
choices=CstType.choices,
default=CstType.BASE
)
convention: TextField = TextField(
verbose_name='Комментарий/Конвенция',
default='',
blank=True
)
term_raw: TextField = TextField(
verbose_name='Термин (с отсылками)',
default='',
blank=True
)
term_resolved: TextField = TextField(
verbose_name='Термин',
default='',
blank=True
)
term_forms: JSONField = JSONField(
verbose_name='Словоформы',
default=_empty_forms
)
definition_formal: TextField = TextField(
verbose_name='Родоструктурное определение',
default='',
blank=True
)
definition_raw: TextField = TextField(
verbose_name='Текстовое определение (с отсылками)',
default='',
blank=True
)
definition_resolved: TextField = TextField(
verbose_name='Текстовое определение',
default='',
blank=True
)
class Meta:
''' Model metadata. '''
verbose_name = 'Конституента'
verbose_name_plural = 'Конституенты'
def get_absolute_url(self):
''' URL access. '''
return reverse('constituenta-detail', kwargs={'pk': self.pk})
def __str__(self) -> str:
return f'{self.alias}'
def set_term_resolved(self, new_term: str):
''' Set term and reset forms if needed. '''
if new_term == self.term_resolved:
return
self.term_resolved = new_term
self.term_forms = []
def apply_mapping(self, mapping: dict[str, str], change_aliases: bool = False):
modified = False
if change_aliases and self.alias in mapping:
modified = True
self.alias = mapping[self.alias]
expression = apply_pattern(self.definition_formal, mapping, _GLOBAL_ID_PATTERN)
if expression != self.definition_formal:
modified = True
self.definition_formal = expression
convention = apply_pattern(self.convention, mapping, _GLOBAL_ID_PATTERN)
if convention != self.convention:
modified = True
self.convention = convention
term = apply_pattern(self.term_raw, mapping, _REF_ENTITY_PATTERN)
if term != self.term_raw:
modified = True
self.term_raw = term
definition = apply_pattern(self.definition_raw, mapping, _REF_ENTITY_PATTERN)
if definition != self.definition_raw:
modified = True
self.definition_raw = definition
return modified

View File

@ -0,0 +1,71 @@
''' Models: Editor. '''
from typing import TYPE_CHECKING
from django.db import transaction
from django.db.models import CASCADE, DateTimeField, ForeignKey, Model
from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Editor(Model):
''' Editor list. '''
item: ForeignKey = ForeignKey(
verbose_name='Схема',
to='rsform.LibraryItem',
on_delete=CASCADE
)
editor: ForeignKey = ForeignKey(
verbose_name='Редактор',
to=User,
on_delete=CASCADE,
null=True
)
time_create: DateTimeField = DateTimeField(
verbose_name='Дата добавления',
auto_now_add=True
)
class Meta:
''' Model metadata. '''
verbose_name = 'Редактор'
verbose_name_plural = 'Редакторы'
unique_together = [['item', 'editor']]
def __str__(self) -> str:
return f'{self.item}: {self.editor}'
@staticmethod
def add(item: 'LibraryItem', user: User) -> bool:
''' Add Editor for item. '''
if Editor.objects.filter(item=item, editor=user).exists():
return False
Editor.objects.create(item=item, editor=user)
return True
@staticmethod
def remove(item: 'LibraryItem', user: User) -> bool:
''' Remove Editor. '''
editor = Editor.objects.filter(item=item, editor=user)
if not editor.exists():
return False
editor.delete()
return True
@staticmethod
@transaction.atomic
def set(item: 'LibraryItem', users: list[User]):
''' Set editors for item. '''
processed: list[User] = []
for editor_item in Editor.objects.filter(item=item):
if not editor_item.editor in users:
editor_item.delete()
else:
processed.append(editor_item.editor)
for user in users:
if not user in processed:
processed.append(user)
Editor.objects.create(item=item, editor=user)

View File

@ -0,0 +1,133 @@
''' Models: LibraryItem. '''
import re
from django.db import transaction
from django.db.models import (
SET_NULL,
BooleanField,
CharField,
DateTimeField,
ForeignKey,
Model,
TextChoices,
TextField
)
from apps.users.models import User
from .Editor import Editor
from .Subscription import Subscription
from .Version import Version
class LibraryItemType(TextChoices):
''' Type of library items '''
RSFORM = 'rsform'
OPERATIONS_SCHEMA = 'oss'
class AccessPolicy(TextChoices):
''' Type of item access policy. '''
PUBLIC = 'public'
PROTECTED = 'protected'
PRIVATE = 'private'
class LocationHead(TextChoices):
''' Location prefixes. '''
PROJECTS = '/P'
LIBRARY = '/L'
USER = '/U'
COMMON = '/S'
_RE_LOCATION = r'^/[PLUS]((/[!\d\w]([!\d\w\- ]*[!\d\w])?)*)?$' # cspell:disable-line
def validate_location(target: str) -> bool:
return bool(re.search(_RE_LOCATION, target))
class LibraryItem(Model):
''' Abstract library item.'''
item_type: CharField = CharField(
verbose_name='Тип',
max_length=50,
choices=LibraryItemType.choices
)
owner: ForeignKey = ForeignKey(
verbose_name='Владелец',
to=User,
on_delete=SET_NULL,
null=True
)
title: TextField = TextField(
verbose_name='Название'
)
alias: CharField = CharField(
verbose_name='Шифр',
max_length=255,
blank=True
)
comment: TextField = TextField(
verbose_name='Комментарий',
blank=True
)
visible: BooleanField = BooleanField(
verbose_name='Отображаемая',
default=True
)
read_only: BooleanField = BooleanField(
verbose_name='Запретить редактирование',
default=False
)
access_policy: CharField = CharField(
verbose_name='Политика доступа',
max_length=500,
choices=AccessPolicy.choices,
default=AccessPolicy.PUBLIC
)
location: TextField = TextField(
verbose_name='Расположение',
max_length=500,
default=LocationHead.USER
)
time_create: DateTimeField = DateTimeField(
verbose_name='Дата создания',
auto_now_add=True
)
time_update: DateTimeField = DateTimeField(
verbose_name='Дата изменения',
auto_now=True
)
class Meta:
''' Model metadata. '''
verbose_name = 'Схема'
verbose_name_plural = 'Схемы'
def __str__(self) -> str:
return f'{self.alias}'
def get_absolute_url(self):
return f'/api/library/{self.pk}'
def subscribers(self) -> list[Subscription]:
''' Get all subscribers for this item. '''
return [subscription.user for subscription in Subscription.objects.filter(item=self.pk)]
def versions(self) -> list[Version]:
''' Get all Versions of this item. '''
return list(Version.objects.filter(item=self.pk).order_by('-time_create'))
def editors(self) -> list[Editor]:
''' Get all Editors of this item. '''
return [item.editor for item in Editor.objects.filter(item=self.pk)]
@transaction.atomic
def save(self, *args, **kwargs):
subscribe = not self.pk and self.owner
super().save(*args, **kwargs)
if subscribe:
Subscription.subscribe(user=self.owner, item=self)

View File

@ -0,0 +1,17 @@
''' Models: LibraryTemplate. '''
from django.db.models import CASCADE, ForeignKey, Model
class LibraryTemplate(Model):
''' Template for library items and constituents. '''
lib_source: ForeignKey = ForeignKey(
verbose_name='Источник',
to='rsform.LibraryItem',
on_delete=CASCADE,
null=True
)
class Meta:
''' Model metadata. '''
verbose_name = 'Шаблон'
verbose_name_plural = 'Шаблоны'

View File

@ -0,0 +1,49 @@
''' Models: Subscription. '''
from typing import TYPE_CHECKING
from django.db.models import CASCADE, ForeignKey, Model
from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Subscription(Model):
''' User subscription to library item. '''
user: ForeignKey = ForeignKey(
verbose_name='Пользователь',
to=User,
on_delete=CASCADE
)
item: ForeignKey = ForeignKey(
verbose_name='Элемент',
to='rsform.LibraryItem',
on_delete=CASCADE
)
class Meta:
''' Model metadata. '''
verbose_name = 'Подписка'
verbose_name_plural = 'Подписки'
unique_together = [['user', 'item']]
def __str__(self) -> str:
return f'{self.user} -> {self.item}'
@staticmethod
def subscribe(user: User, item: 'LibraryItem') -> bool:
''' Add subscription. '''
if Subscription.objects.filter(user=user, item=item).exists():
return False
Subscription.objects.create(user=user, item=item)
return True
@staticmethod
def unsubscribe(user: User, item: 'LibraryItem') -> bool:
''' Remove subscription. '''
sub = Subscription.objects.filter(user=user, item=item)
if not sub.exists():
return False
sub.delete()
return True

View File

@ -0,0 +1,44 @@
''' Models: Version. '''
from django.db.models import (
CASCADE,
CharField,
DateTimeField,
ForeignKey,
JSONField,
Model,
TextField
)
class Version(Model):
''' Library item version archive. '''
item: ForeignKey = ForeignKey(
verbose_name='Схема',
to='rsform.LibraryItem',
on_delete=CASCADE
)
version = CharField(
verbose_name='Версия',
max_length=20,
blank=False
)
description: TextField = TextField(
verbose_name='Описание',
blank=True
)
data: JSONField = JSONField(
verbose_name='Содержание'
)
time_create: DateTimeField = DateTimeField(
verbose_name='Дата создания',
auto_now_add=True
)
class Meta:
''' Model metadata. '''
verbose_name = 'Версия'
verbose_name_plural = 'Версии'
unique_together = [['item', 'version']]
def __str__(self) -> str:
return f'{self.item} v{self.version}'

View File

@ -0,0 +1,16 @@
''' Django: Models. '''
from .api_RSForm import RSForm
from .Constituenta import Constituenta, CstType, _empty_forms
from .Editor import Editor
from .LibraryItem import (
AccessPolicy,
LibraryItem,
LibraryItemType,
LocationHead,
User,
validate_location
)
from .LibraryTemplate import LibraryTemplate
from .Subscription import Subscription
from .Version import Version

View File

@ -0,0 +1,616 @@
''' Models: RSForm API. '''
from copy import deepcopy
from typing import Optional, Union, cast
from cctext import Entity, Resolver, TermForm, extract_entities, split_grams
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from .. import messages as msg
from ..graph import Graph
from .api_RSLanguage import (
extract_globals,
generate_structure,
get_type_prefix,
guess_type,
infer_template,
is_base_set,
is_functional,
is_simple_expression,
split_template
)
from .Constituenta import Constituenta, CstType
from .LibraryItem import LibraryItem, LibraryItemType
from .Version import Version
_INSERT_LAST: int = -1
class RSForm:
''' RSForm is math form of conceptual schema. '''
def __init__(self, item: LibraryItem):
if item.item_type != LibraryItemType.RSFORM:
raise ValueError(msg.libraryTypeUnexpected())
self.item = item
@staticmethod
def create(**kwargs) -> 'RSForm':
return RSForm(LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, **kwargs))
def constituents(self) -> QuerySet['Constituenta']:
''' Get QuerySet containing all constituents of current RSForm. '''
return Constituenta.objects.filter(schema=self.item)
def resolver(self) -> Resolver:
''' Create resolver for text references based on schema terms. '''
result = Resolver({})
for cst in self.constituents():
entity = Entity(
alias=cst.alias,
nominal=cst.term_resolved,
manual_forms=[
TermForm(text=form['text'], grams=split_grams(form['tags']))
for form in cst.term_forms
]
)
result.context[cst.alias] = entity
return result
def semantic(self) -> 'SemanticInfo':
''' Access semantic information on constituents. '''
return SemanticInfo(self)
@transaction.atomic
def on_term_change(self, changed: list[int]):
''' Trigger cascade resolutions when term changes. '''
graph_terms = self._graph_term()
expansion = graph_terms.expand_outputs(changed)
expanded_change = changed + expansion
resolver = self.resolver()
if len(expansion) > 0:
for cst_id in graph_terms.topological_order():
if cst_id not in expansion:
continue
cst = self.constituents().get(id=cst_id)
resolved = resolver.resolve(cst.term_raw)
if resolved == cst.term_resolved:
continue
cst.set_term_resolved(resolved)
cst.save()
resolver.context[cst.alias] = Entity(cst.alias, resolved)
graph_defs = self._graph_text()
update_defs = set(expansion + graph_defs.expand_outputs(expanded_change)).union(changed)
if len(update_defs) == 0:
return
for cst_id in update_defs:
cst = self.constituents().get(id=cst_id)
resolved = resolver.resolve(cst.definition_raw)
if resolved == cst.definition_resolved:
continue
cst.definition_resolved = resolved
cst.save()
def get_max_index(self, cst_type: CstType) -> int:
''' Get maximum alias index for specific CstType. '''
result: int = 0
items = Constituenta.objects \
.filter(schema=self.item, cst_type=cst_type) \
.order_by('-alias') \
.values_list('alias', flat=True)
for alias in items:
result = max(result, int(alias[1:]))
return result
@transaction.atomic
def insert_new(
self,
alias: str,
cst_type: Union[CstType, None] = None,
position: int = _INSERT_LAST,
**kwargs
) -> Constituenta:
''' Insert new constituenta at given position.
All following constituents order is shifted by 1 position. '''
if self.constituents().filter(alias=alias).exists():
raise ValidationError(msg.aliasTaken(alias))
position = self._get_insert_position(position)
if cst_type is None:
cst_type = guess_type(alias)
self._shift_positions(position, 1)
result = Constituenta.objects.create(
schema=self.item,
order=position,
alias=alias,
cst_type=cst_type,
**kwargs
)
self.item.save()
result.refresh_from_db()
return result
@transaction.atomic
def insert_copy(self, items: list[Constituenta], position: int = _INSERT_LAST) -> list[Constituenta]:
''' Insert copy of target constituents updating references. '''
count = len(items)
if count == 0:
return []
position = self._get_insert_position(position)
self._shift_positions(position, count)
indices: dict[str, int] = {}
for (value, _) in CstType.choices:
indices[value] = self.get_max_index(cast(CstType, value))
mapping: dict[str, str] = {}
for cst in items:
indices[cst.cst_type] = indices[cst.cst_type] + 1
newAlias = f'{get_type_prefix(cst.cst_type)}{indices[cst.cst_type]}'
mapping[cst.alias] = newAlias
result = deepcopy(items)
for cst in result:
cst.pk = None
cst.schema = self.item
cst.order = position
cst.alias = mapping[cst.alias]
cst.apply_mapping(mapping)
cst.save()
position = position + 1
self.item.save()
return result
@transaction.atomic
def move_cst(self, listCst: list[Constituenta], target: int):
''' Move list of constituents to specific position '''
count_moved = 0
count_top = 0
count_bot = 0
size = len(listCst)
update_list = []
for cst in self.constituents().only('id', 'order').order_by('order'):
if cst not in listCst:
if count_top + 1 < target:
cst.order = count_top + 1
count_top += 1
else:
cst.order = target + size + count_bot
count_bot += 1
else:
cst.order = target + count_moved
count_moved += 1
update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['order'])
self.item.save()
@transaction.atomic
def delete_cst(self, listCst):
''' Delete multiple constituents. Do not check if listCst are from this schema. '''
for cst in listCst:
cst.delete()
self._reset_order()
self.resolve_all_text()
self.item.save()
@transaction.atomic
def create_cst(self, data: dict, insert_after: Optional[str] = None) -> Constituenta:
''' Create new cst from data. '''
resolver = self.resolver()
cst = self._insert_new(data, insert_after)
cst.convention = data.get('convention', '')
cst.definition_formal = data.get('definition_formal', '')
cst.term_forms = data.get('term_forms', [])
cst.term_raw = data.get('term_raw', '')
if cst.term_raw != '':
resolved = resolver.resolve(cst.term_raw)
cst.term_resolved = resolved
resolver.context[cst.alias] = Entity(cst.alias, resolved)
cst.definition_raw = data.get('definition_raw', '')
if cst.definition_raw != '':
cst.definition_resolved = resolver.resolve(cst.definition_raw)
cst.save()
self.on_term_change([cst.id])
cst.refresh_from_db()
return cst
@transaction.atomic
def substitute(
self,
original: Constituenta,
substitution: Constituenta,
transfer_term: bool
):
''' Execute constituenta substitution. '''
assert original.pk != substitution.pk
mapping = {original.alias: substitution.alias}
self.apply_mapping(mapping)
if transfer_term:
substitution.term_raw = original.term_raw
substitution.term_forms = original.term_forms
substitution.term_resolved = original.term_resolved
substitution.save()
original.delete()
self.on_term_change([substitution.id])
def restore_order(self):
''' Restore order based on types and term graph. '''
manager = _OrderManager(self)
manager.restore_order()
def reset_aliases(self):
''' Recreate all aliases based on constituents order. '''
mapping = self._create_reset_mapping()
self.apply_mapping(mapping, change_aliases=True)
def _create_reset_mapping(self) -> dict[str, str]:
bases = cast(dict[str, int], {})
mapping = cast(dict[str, str], {})
for cst_type in CstType.values:
bases[cst_type] = 1
cst_list = self.constituents().order_by('order')
for cst in cst_list:
alias = f'{get_type_prefix(cst.cst_type)}{bases[cst.cst_type]}'
bases[cst.cst_type] += 1
if cst.alias != alias:
mapping[cst.alias] = alias
return mapping
@transaction.atomic
def apply_mapping(self, mapping: dict[str, str], change_aliases: bool = False):
''' Apply rename mapping. '''
cst_list = self.constituents().order_by('order')
for cst in cst_list:
if cst.apply_mapping(mapping, change_aliases):
cst.save()
@transaction.atomic
def resolve_all_text(self):
''' Trigger reference resolution for all texts. '''
graph_terms = self._graph_term()
resolver = Resolver({})
for cst_id in graph_terms.topological_order():
cst = self.constituents().get(id=cst_id)
resolved = resolver.resolve(cst.term_raw)
resolver.context[cst.alias] = Entity(cst.alias, resolved)
if resolved != cst.term_resolved:
cst.term_resolved = resolved
cst.save()
for cst in self.constituents():
resolved = resolver.resolve(cst.definition_raw)
if resolved != cst.definition_resolved:
cst.definition_resolved = resolved
cst.save()
@transaction.atomic
def create_version(self, version: str, description: str, data) -> Version:
''' Creates version for current state. '''
return Version.objects.create(
item=self.item,
version=version,
description=description,
data=data
)
@transaction.atomic
def produce_structure(self, target: Constituenta, parse: dict) -> list[int]:
''' Add constituents for each structural element of the target. '''
expressions = generate_structure(
alias=target.alias,
expression=target.definition_formal,
parse=parse
)
count_new = len(expressions)
if count_new == 0:
return []
position = target.order + 1
self._shift_positions(position, count_new)
result = []
cst_type = CstType.TERM if len(parse['args']) == 0 else CstType.FUNCTION
free_index = self.get_max_index(cst_type) + 1
prefix = get_type_prefix(cst_type)
for text in expressions:
new_item = Constituenta.objects.create(
schema=self.item,
order=position,
alias=f'{prefix}{free_index}',
definition_formal=text,
cst_type=cst_type
)
result.append(new_item.id)
free_index = free_index + 1
position = position + 1
self.item.save()
return result
def _shift_positions(self, start: int, shift: int):
if shift == 0:
return
update_list = \
Constituenta.objects \
.only('id', 'order', 'schema') \
.filter(schema=self.item, order__gte=start)
for cst in update_list:
cst.order += shift
Constituenta.objects.bulk_update(update_list, ['order'])
def _get_last_position(self):
if self.constituents().exists():
return self.constituents().count()
else:
return 0
def _get_insert_position(self, position: int) -> int:
if position <= 0 and position != _INSERT_LAST:
raise ValidationError(msg.invalidPosition())
lastPosition = self._get_last_position()
if position == _INSERT_LAST:
position = lastPosition + 1
else:
position = max(1, min(position, lastPosition + 1))
return position
@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:
cst_after = Constituenta.objects.get(pk=insert_after)
return self.insert_new(data['alias'], data['cst_type'], cst_after.order + 1)
else:
return self.insert_new(data['alias'], data['cst_type'])
def _graph_formal(self) -> Graph[int]:
''' Graph based on formal definitions. '''
result: Graph[int] = Graph()
cst_list = \
self.constituents() \
.only('id', 'order', 'alias', 'definition_formal') \
.order_by('order')
for cst in cst_list:
result.add_node(cst.id)
for cst in cst_list:
for alias in extract_globals(cst.definition_formal):
try:
child = cst_list.get(alias=alias)
result.add_edge(src=child.id, dest=cst.id)
except Constituenta.DoesNotExist:
pass
return result
def _graph_term(self) -> Graph[int]:
''' Graph based on term texts. '''
result: Graph[int] = Graph()
cst_list = \
self.constituents() \
.only('id', 'order', 'alias', 'term_raw') \
.order_by('order')
for cst in cst_list:
result.add_node(cst.id)
for cst in cst_list:
for alias in extract_entities(cst.term_raw):
try:
child = cst_list.get(alias=alias)
result.add_edge(src=child.id, dest=cst.id)
except Constituenta.DoesNotExist:
pass
return result
def _graph_text(self) -> Graph[int]:
''' Graph based on definition texts. '''
result: Graph[int] = Graph()
cst_list = \
self.constituents() \
.only('id', 'order', 'alias', 'definition_raw') \
.order_by('order')
for cst in cst_list:
result.add_node(cst.id)
for cst in cst_list:
for alias in extract_entities(cst.definition_raw):
try:
child = cst_list.get(alias=alias)
result.add_edge(src=child.id, dest=cst.id)
except Constituenta.DoesNotExist:
pass
return result
class SemanticInfo:
''' Semantic information derived from constituents. '''
def __init__(self, schema: RSForm):
self._graph = schema._graph_formal()
self._items = list(
schema.constituents()
.only('id', 'alias', 'cst_type', 'definition_formal')
.order_by('order')
)
self._cst_by_alias = {cst.alias: cst for cst in self._items}
self._cst_by_ID = {cst.id: cst for cst in self._items}
self.info = {
cst.id: {
'is_simple': False,
'is_template': False,
'parent': cst.id,
'children': []
}
for cst in self._items
}
self._calculate_attributes()
def __getitem__(self, key: int) -> dict:
return self.info[key]
def is_simple_expression(self, target: int) -> bool:
''' Access "is_simple" attribute. '''
return cast(bool, self.info[target]['is_simple'])
def is_template(self, target: int) -> bool:
''' Access "is_template" attribute. '''
return cast(bool, self.info[target]['is_template'])
def parent(self, target: int) -> int:
''' Access "parent" attribute. '''
return cast(int, self.info[target]['parent'])
def children(self, target: int) -> list[int]:
''' Access "children" attribute. '''
return cast(list[int], self.info[target]['children'])
def _calculate_attributes(self):
for cst_id in self._graph.topological_order():
cst = self._cst_by_ID[cst_id]
self.info[cst_id]['is_template'] = infer_template(cst.definition_formal)
self.info[cst_id]['is_simple'] = self._infer_simple_expression(cst)
if not self.info[cst_id]['is_simple'] or cst.cst_type == CstType.STRUCTURED:
continue
parent = self._infer_parent(cst)
self.info[cst_id]['parent'] = parent
if parent != cst_id:
self.info[parent]['children'].append(cst_id)
def _infer_simple_expression(self, target: Constituenta) -> bool:
if target.cst_type == CstType.STRUCTURED or is_base_set(target.cst_type):
return False
dependencies = self._graph.inputs[target.id]
has_complex_dependency = any(
self.is_template(cst_id) and
not self.is_simple_expression(cst_id) for cst_id in dependencies
)
if has_complex_dependency:
return False
if is_functional(target.cst_type):
return is_simple_expression(split_template(target.definition_formal)['body'])
else:
return is_simple_expression(target.definition_formal)
def _infer_parent(self, target: Constituenta) -> int:
sources = self._extract_sources(target)
if len(sources) != 1:
return target.id
parent_id = next(iter(sources))
parent = self._cst_by_ID[parent_id]
if is_base_set(parent.cst_type):
return target.id
return parent_id
def _extract_sources(self, target: Constituenta) -> set[int]:
sources: set[int] = set()
if not is_functional(target.cst_type):
for parent_id in self._graph.inputs[target.id]:
parent_info = self[parent_id]
if not parent_info['is_template'] or not parent_info['is_simple']:
sources.add(parent_info['parent'])
return sources
expression = split_template(target.definition_formal)
body_dependencies = extract_globals(expression['body'])
for alias in body_dependencies:
parent = self._cst_by_alias.get(alias)
if not parent:
continue
parent_info = self[parent.id]
if not parent_info['is_template'] or not parent_info['is_simple']:
sources.add(parent_info['parent'])
if self._need_check_head(sources, expression['head']):
head_dependencies = extract_globals(expression['head'])
for alias in head_dependencies:
parent = self._cst_by_alias.get(alias)
if not parent:
continue
parent_info = self[parent.id]
if not is_base_set(parent.cst_type) and \
(not parent_info['is_template'] or not parent_info['is_simple']):
sources.add(parent_info['parent'])
return sources
def _need_check_head(self, sources: set[int], head: str) -> bool:
if len(sources) == 0:
return True
elif len(sources) != 1:
return False
else:
base = self._cst_by_ID[next(iter(sources))]
return not is_functional(base.cst_type) or \
split_template(base.definition_formal)['head'] != head
class _OrderManager:
''' Ordering helper class '''
def __init__(self, schema: RSForm):
self._semantic = schema.semantic()
self._graph = schema._graph_formal()
self._items = list(
schema.constituents()
.only('id', 'order', 'alias', 'cst_type', 'definition_formal')
.order_by('order')
)
self._cst_by_ID = {cst.id: cst for cst in self._items}
def restore_order(self) -> None:
''' Implement order restoration process. '''
if len(self._items) <= 1:
return
self._fix_kernel()
self._fix_topological()
self._fix_semantic_children()
self._save_order()
def _fix_topological(self) -> None:
sorted_ids = self._graph.sort_stable([cst.id for cst in self._items])
sorted_items = [next(cst for cst in self._items if cst.id == id) for id in sorted_ids]
self._items = sorted_items
def _fix_kernel(self) -> None:
result = [cst for cst in self._items if cst.cst_type == CstType.BASE]
result = result + [cst for cst in self._items if cst.cst_type == CstType.CONSTANT]
kernel = [
cst.id for cst in self._items if
cst.cst_type in [CstType.STRUCTURED, CstType.AXIOM] or
self._cst_by_ID[self._semantic.parent(cst.id)].cst_type == CstType.STRUCTURED
]
kernel = kernel + self._graph.expand_inputs(kernel)
result = result + [cst for cst in self._items if result.count(cst) == 0 and cst.id in kernel]
result = result + [cst for cst in self._items if result.count(cst) == 0]
self._items = result
def _fix_semantic_children(self) -> None:
result: list[Constituenta] = []
marked: set[Constituenta] = set()
for cst in self._items:
if cst in marked:
continue
result.append(cst)
children = self._semantic[cst.id]['children']
if len(children) == 0:
continue
for child in self._items:
if child.id in children:
marked.add(child)
result.append(child)
self._items = result
@transaction.atomic
def _save_order(self) -> None:
order = 1
for cst in self._items:
cst.order = order
cst.save()
order += 1

View File

@ -0,0 +1,183 @@
''' Models: Definitions and utility function for RSLanguage. '''
import json
import re
from enum import IntEnum, unique
from typing import Set, Tuple, cast
import pyconcept
from .. import messages as msg
from .Constituenta import CstType
_RE_GLOBALS = r'[XCSADFPT]\d+' # cspell:disable-line
_RE_TEMPLATE = r'R\d+'
_RE_COMPLEX_SYMBOLS = r'[∀∃×ℬ;|:]'
@unique
class TokenType(IntEnum):
''' Some of grammar token types. Full list seek in frontend / pyconcept '''
ID_GLOBAL = 259
ID_RADICAL = 262
DECART = 287
BOOLEAN = 292
BIGPR = 293
SMALLPR = 294
REDUCE = 299
def get_type_prefix(cst_type: CstType) -> str:
''' Get alias prefix. '''
match cst_type:
case CstType.BASE: return 'X'
case CstType.CONSTANT: return 'C'
case CstType.STRUCTURED: return 'S'
case CstType.AXIOM: return 'A'
case CstType.TERM: return 'D'
case CstType.FUNCTION: return 'F'
case CstType.PREDICATE: return 'P'
case CstType.THEOREM: return 'T'
return 'X'
def is_basic_concept(cst_type: CstType) -> bool:
''' Evaluate if CstType is basic concept.'''
return cst_type in [
CstType.BASE,
CstType.CONSTANT,
CstType.STRUCTURED,
CstType.AXIOM
]
def is_base_set(cst_type: CstType) -> bool:
''' Evaluate if CstType is base set or constant set.'''
return cst_type in [
CstType.BASE,
CstType.CONSTANT
]
def is_functional(cst_type: CstType) -> bool:
''' Evaluate if CstType is function.'''
return cst_type in [
CstType.FUNCTION,
CstType.PREDICATE
]
def extract_globals(expression: str) -> Set[str]:
''' Extract all global aliases from expression. '''
return set(re.findall(_RE_GLOBALS, expression))
def guess_type(alias: str) -> CstType:
''' Get CstType for alias. '''
prefix = alias[0]
for (value, _) in CstType.choices:
if prefix == get_type_prefix(cast(CstType, value)):
return cast(CstType, value)
return CstType.BASE
def _get_structure_prefix(alias: str, expression: str, parse: dict) -> Tuple[str, str]:
''' Generate prefix and alias for structure generation. '''
args = parse['args']
if len(args) == 0:
return (alias, '')
prefix = expression[0:expression.find(']')] + '] '
newAlias = alias + '[' + ','.join([arg['alias'] for arg in args]) + ']'
return (newAlias, prefix)
def infer_template(expression: str) -> bool:
''' Checks if given expression is a template. '''
return bool(re.search(_RE_TEMPLATE, expression))
def is_simple_expression(expression: str) -> bool:
''' Checks if given expression is "simple". '''
return not bool(re.search(_RE_COMPLEX_SYMBOLS, expression))
def split_template(expression: str):
''' Splits a string containing a template definition into its head and body parts. '''
start = 0
for index, char in enumerate(expression):
start = index
if char == '[':
break
if start < len(expression):
counter = 0
for end in range(start + 1, len(expression)):
if expression[end] == '[':
counter += 1
elif expression[end] == ']':
if counter != 0:
counter -= 1
else:
return {
'head': expression[start + 1:end].strip(),
'body': expression[end + 1:].strip()
}
return {
'head': '',
'body': expression
}
def generate_structure(alias: str, expression: str, parse: dict) -> list:
''' Generate list of expressions for target structure. '''
ast = json.loads(pyconcept.parse_expression(parse['typification']))['ast']
if len(ast) == 0:
raise ValueError(msg.typificationInvalidStr())
if len(ast) == 1:
return []
(link, prefix) = _get_structure_prefix(alias, expression, parse)
generated: list = []
arity: list = [1] * len(ast)
for (n, item) in enumerate(ast):
if n == 0:
generated.append({
'text': link, # generated text
'operation': None, # applied operation. None if text should be skipped
'is_boolean': False # is the result of operation has an additional boolean
})
continue
parent_index = item['parent']
parent_type = ast[parent_index]['typeID']
parent_text = generated[parent_index]['text']
parent_is_boolean = generated[parent_index]['is_boolean']
assert parent_type in [TokenType.BOOLEAN, TokenType.DECART]
if parent_is_boolean:
if parent_type == TokenType.BOOLEAN:
generated.append({
'text': f'red({parent_text})',
'operation': TokenType.REDUCE,
'is_boolean': True
})
if parent_type == TokenType.DECART:
generated.append({
'text': f'Pr{arity[parent_index]}({parent_text})',
'operation': TokenType.BIGPR,
'is_boolean': True
})
arity[parent_index] = arity[parent_index] + 1
else:
if parent_type == TokenType.BOOLEAN:
generated.append({
'text': parent_text,
'operation': None,
'is_boolean': True
})
if parent_type == TokenType.DECART:
generated.append({
'text': f'pr{arity[parent_index]}({parent_text})',
'operation': TokenType.SMALLPR,
'is_boolean': False
})
arity[parent_index] = arity[parent_index] + 1
return [prefix + item['text'] for item in generated if item['operation'] is not None]

View File

@ -0,0 +1,93 @@
''' Custom Permission classes.
Hierarchy: Anyone -> User -> Editor -> Owner -> Admin
'''
from typing import Any, cast
from django.core.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny as Anyone # pylint: disable=unused-import
from rest_framework.permissions import BasePermission as _Base
from rest_framework.permissions import \
IsAuthenticated as GlobalUser # pylint: disable=unused-import
from rest_framework.request import Request
from rest_framework.views import APIView
from . import models as m
def _extract_item(obj: Any) -> m.LibraryItem:
if isinstance(obj, m.LibraryItem):
return obj
elif isinstance(obj, m.Constituenta):
return cast(m.LibraryItem, obj.schema)
elif isinstance(obj, (m.Version, m.Subscription, m.Editor)):
return cast(m.LibraryItem, obj.item)
raise PermissionDenied({
'message': 'Invalid type error. Please contact developers',
'object_id': obj.id
})
class GlobalAdmin(_Base):
''' Item permission: Admin or higher. '''
def has_permission(self, request: Request, view: APIView) -> bool:
if not hasattr(request.user, 'is_staff'):
return False
return request.user.is_staff # type: ignore
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if not hasattr(request.user, 'is_staff'):
return False
return request.user.is_staff # type: ignore
class ItemOwner(GlobalAdmin):
''' Item permission: Owner or higher. '''
def has_permission(self, request: Request, view: APIView) -> bool:
return not request.user.is_anonymous
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if request.user == _extract_item(obj).owner:
return True
return super().has_object_permission(request, view, obj)
class ItemEditor(ItemOwner):
''' Item permission: Editor or higher. '''
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
if request.user.is_anonymous:
return False
item = _extract_item(obj)
if m.Editor.objects.filter(
item=item,
editor=cast(m.User, request.user)
).exists() and item.access_policy != m.AccessPolicy.PRIVATE:
return True
return super().has_object_permission(request, view, obj)
class ItemAnyone(ItemEditor):
''' Item permission: Anyone if public. '''
def has_permission(self, request: Request, view: APIView) -> bool:
return True
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
item = _extract_item(obj)
if item.access_policy == m.AccessPolicy.PUBLIC:
return True
return super().has_object_permission(request, view, obj)
class EditorMixin(APIView):
''' Editor permissions mixin for API views. '''
def get_permissions(self):
result = super().get_permissions()
if self.request.method.upper() == 'GET':
result.append(Anyone())
else:
result.append(ItemEditor())
return result

View File

@ -0,0 +1,40 @@
''' REST API: Serializers. '''
from .basics import (
AccessPolicySerializer,
ASTNodeSerializer,
ExpressionParseSerializer,
ExpressionSerializer,
LocationSerializer,
MultiFormSerializer,
ResolverSerializer,
TextSerializer,
WordFormSerializer
)
from .data_access import (
CstCreateSerializer,
CstListSerializer,
CstMoveSerializer,
CstRenameSerializer,
CstSerializer,
CstSubstituteSerializer,
CstTargetSerializer,
InlineSynthesisSerializer,
LibraryItemBaseSerializer,
LibraryItemCloneSerializer,
LibraryItemSerializer,
RSFormParseSerializer,
RSFormSerializer,
UsersListSerializer,
UserTargetSerializer,
VersionCreateSerializer,
VersionSerializer
)
from .io_files import FileSerializer, RSFormTRSSerializer, RSFormUploadSerializer
from .io_pyconcept import PyConceptAdapter
from .schema_typing import (
NewCstResponse,
NewMultiCstResponse,
NewVersionResponse,
ResultTextResponse
)

View File

@ -0,0 +1,192 @@
''' Basic serializers that do not interact with database. '''
from typing import cast
from cctext import EntityReference, Reference, ReferenceType, Resolver, SyntacticReference
from rest_framework import serializers
from .. import messages as msg
from ..models import AccessPolicy, validate_location
class ExpressionSerializer(serializers.Serializer):
''' Serializer: RSLang expression. '''
expression = serializers.CharField()
class WordFormSerializer(serializers.Serializer):
''' Serializer: inflect request. '''
text = serializers.CharField()
grams = serializers.CharField()
class LocationSerializer(serializers.Serializer):
''' Serializer: Item location. '''
location = serializers.CharField(max_length=500)
def validate(self, attrs):
attrs = super().validate(attrs)
if not validate_location(attrs['location']):
raise serializers.ValidationError({
'location': msg.invalidLocation()
})
return attrs
class AccessPolicySerializer(serializers.Serializer):
''' Serializer: Constituenta renaming. '''
access_policy = serializers.CharField(max_length=500)
def validate(self, attrs):
attrs = super().validate(attrs)
if not attrs['access_policy'] in AccessPolicy.values:
raise serializers.ValidationError({
'access_policy': msg.invalidEnum(attrs['access_policy'])
})
return attrs
class MultiFormSerializer(serializers.Serializer):
''' Serializer: inflect request. '''
items = serializers.ListField(
child=WordFormSerializer()
)
@staticmethod
def from_list(data: list[tuple[str, str]]) -> dict:
result: dict = {}
result['items'] = []
for item in data:
result['items'].append({
'text': item[0],
'grams': item[1]
})
return result
class TextSerializer(serializers.Serializer):
''' Serializer: Text with references. '''
text = serializers.CharField()
class FunctionArgSerializer(serializers.Serializer):
''' Serializer: RSLang function argument type. '''
alias = serializers.CharField()
typification = serializers.CharField()
class CstParseSerializer(serializers.Serializer):
''' Serializer: Constituenta parse result. '''
status = serializers.CharField()
valueClass = serializers.CharField()
typification = serializers.CharField()
syntaxTree = serializers.CharField()
args = serializers.ListField(
child=FunctionArgSerializer()
)
class ErrorDescriptionSerializer(serializers.Serializer):
''' Serializer: RSError description. '''
errorType = serializers.IntegerField()
position = serializers.IntegerField()
isCritical = serializers.BooleanField()
params = serializers.ListField(
child=serializers.CharField()
)
class NodeDataSerializer(serializers.Serializer):
''' Serializer: Node data. '''
dataType = serializers.CharField()
value = serializers.CharField()
class ASTNodeSerializer(serializers.Serializer):
''' Serializer: Syntax tree node. '''
uid = serializers.IntegerField()
parent = serializers.IntegerField() # type: ignore
typeID = serializers.IntegerField()
start = serializers.IntegerField()
finish = serializers.IntegerField()
data = NodeDataSerializer() # type: ignore
class ExpressionParseSerializer(serializers.Serializer):
''' Serializer: RSlang expression parse result. '''
parseResult = serializers.BooleanField()
syntax = serializers.CharField()
typification = serializers.CharField()
valueClass = serializers.CharField()
astText = serializers.CharField()
ast = serializers.ListField(
child=ASTNodeSerializer()
)
errors = serializers.ListField( # type: ignore
child=ErrorDescriptionSerializer()
)
args = serializers.ListField(
child=FunctionArgSerializer()
)
class TextPositionSerializer(serializers.Serializer):
''' Serializer: Text position. '''
start = serializers.IntegerField()
finish = serializers.IntegerField()
class ReferenceDataSerializer(serializers.Serializer):
''' Serializer: Reference data - Union of all references. '''
offset = serializers.IntegerField()
nominal = serializers.CharField()
entity = serializers.CharField()
form = serializers.CharField()
class ReferenceSerializer(serializers.Serializer):
''' Serializer: Language reference. '''
type = serializers.CharField()
data = ReferenceDataSerializer() # type: ignore
pos_input = TextPositionSerializer()
pos_output = TextPositionSerializer()
class ResolverSerializer(serializers.Serializer):
''' Serializer: Resolver results serializer. '''
input = serializers.CharField()
output = serializers.CharField()
refs = serializers.ListField(
child=ReferenceSerializer()
)
def to_representation(self, instance: Resolver) -> dict:
return {
'input': instance.input,
'output': instance.output,
'refs': [{
'type': ref.ref.get_type().value,
'data': self._get_reference_data(ref.ref),
'resolved': ref.resolved,
'pos_input': {
'start': ref.pos_input.start,
'finish': ref.pos_input.finish
},
'pos_output': {
'start': ref.pos_output.start,
'finish': ref.pos_output.finish
}
} for ref in instance.refs]
}
@staticmethod
def _get_reference_data(ref: Reference) -> dict:
if ref.get_type() == ReferenceType.entity:
return {
'entity': cast(EntityReference, ref).entity,
'form': cast(EntityReference, ref).form
}
else:
return {
'offset': cast(SyntacticReference, ref).offset,
'nominal': cast(SyntacticReference, ref).nominal
}

View File

@ -0,0 +1,446 @@
''' Serializers for persistent data manipulation. '''
from typing import Optional, cast
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db import transaction
from rest_framework import serializers
from rest_framework.serializers import PrimaryKeyRelatedField as PKField
from .. import messages as msg
from ..models import Constituenta, CstType, LibraryItem, RSForm, Version
from .basics import CstParseSerializer
from .io_pyconcept import PyConceptAdapter
class LibraryItemBaseSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem entry full access. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
read_only_fields = ('id',)
class LibraryItemSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem entry limited access. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
read_only_fields = ('id', 'item_type', 'owner', 'location', 'access_policy')
class LibraryItemCloneSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem cloning. '''
items = PKField(many=True, required=False, queryset=Constituenta.objects.all())
class Meta:
''' serializer metadata. '''
model = LibraryItem
exclude = ['id', 'item_type', 'owner']
class VersionSerializer(serializers.ModelSerializer):
''' Serializer: Version data. '''
class Meta:
''' serializer metadata. '''
model = Version
fields = 'id', 'version', 'item', 'description', 'time_create'
read_only_fields = ('id', 'item', 'time_create')
class VersionInnerSerializer(serializers.ModelSerializer):
''' Serializer: Version data for list of versions. '''
class Meta:
''' serializer metadata. '''
model = Version
fields = 'id', 'version', 'description', 'time_create'
read_only_fields = ('id', 'item', 'time_create')
class VersionCreateSerializer(serializers.ModelSerializer):
''' Serializer: Version create data. '''
class Meta:
''' serializer metadata. '''
model = Version
fields = 'version', 'description'
class LibraryItemDetailsSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem detailed data. '''
subscribers = serializers.SerializerMethodField()
editors = serializers.SerializerMethodField()
versions = serializers.SerializerMethodField()
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
read_only_fields = ('owner', 'id', 'item_type')
def get_subscribers(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.subscribers()]
def get_editors(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.editors()]
def get_versions(self, instance: LibraryItem) -> list:
return [VersionInnerSerializer(item).data for item in instance.versions()]
class CstBaseSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta all data. '''
class Meta:
''' serializer metadata. '''
model = Constituenta
fields = '__all__'
read_only_fields = ('id',)
class CstSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta data. '''
class Meta:
''' serializer metadata. '''
model = Constituenta
fields = '__all__'
read_only_fields = ('id', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
def update(self, instance: Constituenta, validated_data) -> Constituenta:
data = validated_data # Note: use alias for better code readability
schema = RSForm(instance.schema)
definition: Optional[str] = data['definition_raw'] if 'definition_raw' in data else None
term: Optional[str] = data['term_raw'] if 'term_raw' in data else None
term_changed = 'term_forms' in data
if definition is not None and definition != instance.definition_raw:
data['definition_resolved'] = schema.resolver().resolve(definition)
if term is not None and term != instance.term_raw:
data['term_resolved'] = schema.resolver().resolve(term)
if data['term_resolved'] != instance.term_resolved and 'term_forms' not in data:
data['term_forms'] = []
term_changed = data['term_resolved'] != instance.term_resolved
result: Constituenta = super().update(instance, data)
if term_changed:
schema.on_term_change([result.id])
result.refresh_from_db()
schema.item.save()
return result
class CstDetailsSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta data including parse. '''
parse = CstParseSerializer()
class Meta:
''' serializer metadata. '''
model = Constituenta
fields = '__all__'
class CstCreateSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta creation. '''
insert_after = serializers.IntegerField(required=False, allow_null=True)
class Meta:
''' serializer metadata. '''
model = Constituenta
fields = \
'alias', 'cst_type', 'convention', \
'term_raw', 'definition_raw', 'definition_formal', \
'insert_after', 'term_forms'
class RSFormSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm. '''
subscribers = serializers.ListField(
child=serializers.IntegerField()
)
editors = serializers.ListField(
child=serializers.IntegerField()
)
items = serializers.ListField(
child=CstSerializer()
)
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
def to_representation(self, instance: LibraryItem) -> dict:
result = LibraryItemDetailsSerializer(instance).data
schema = RSForm(instance)
result['items'] = []
for cst in schema.constituents().order_by('order'):
result['items'].append(CstSerializer(cst).data)
return result
def to_versioned_data(self) -> dict:
''' Create serializable version representation without redundant data. '''
result = self.to_representation(cast(LibraryItem, self.instance))
del result['versions']
del result['subscribers']
del result['editors']
del result['owner']
del result['visible']
del result['read_only']
del result['access_policy']
del result['location']
del result['time_create']
del result['time_update']
return result
def from_versioned_data(self, version: int, data: dict) -> dict:
''' Load data from version. '''
result = self.to_representation(cast(LibraryItem, self.instance))
result['version'] = version
return result | data
@transaction.atomic
def restore_from_version(self, data: dict):
''' Load data from version. '''
schema = RSForm(cast(LibraryItem, self.instance))
items: list[dict] = data['items']
ids: list[int] = [item['id'] for item in items]
processed: list[int] = []
for cst in schema.constituents():
if not cst.pk in ids:
cst.delete()
else:
cst_data = next(x for x in items if x['id'] == cst.pk)
new_cst = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True)
new_cst.update(
instance=cst,
validated_data=new_cst.validated_data
)
processed.append(cst.pk)
for cst_data in items:
if cst_data['id'] not in processed:
cst = schema.insert_new(cst_data['alias'])
cst_data['id'] = cst.pk
new_cst = CstBaseSerializer(data=cst_data)
new_cst.is_valid(raise_exception=True)
new_cst.update(
instance=cst,
validated_data=new_cst.validated_data
)
loaded_item = LibraryItemBaseSerializer(data=data)
loaded_item.is_valid(raise_exception=True)
loaded_item.update(
instance=cast(LibraryItem, self.instance),
validated_data=loaded_item.validated_data
)
class RSFormParseSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm including parse. '''
subscribers = serializers.ListField(
child=serializers.IntegerField()
)
editors = serializers.ListField(
child=serializers.IntegerField()
)
items = serializers.ListField(
child=CstDetailsSerializer()
)
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
def to_representation(self, instance: LibraryItem):
result = RSFormSerializer(instance).data
return self._parse_data(result)
def from_versioned_data(self, version: int, data: dict) -> dict:
''' Load data from version and parse. '''
item = cast(LibraryItem, self.instance)
result = RSFormSerializer(item).from_versioned_data(version, data)
return self._parse_data(result)
def _parse_data(self, data: dict) -> dict:
parse = PyConceptAdapter(data).parse()
for cst_data in data['items']:
cst_data['parse'] = next(
cst['parse'] for cst in parse['items']
if cst['id'] == cst_data['id']
)
return data
class CstTargetSerializer(serializers.Serializer):
''' Serializer: Target single Constituenta. '''
target = PKField(many=False, queryset=Constituenta.objects.all())
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
cst = cast(Constituenta, attrs['target'])
if schema and cst.schema != schema:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title)
})
if cst.cst_type not in [CstType.FUNCTION, CstType.STRUCTURED, CstType.TERM]:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNoStructure()
})
self.instance = cst
return attrs
class UserTargetSerializer(serializers.Serializer):
''' Serializer: Target single User. '''
user = PKField(many=False, queryset=User.objects.all())
class UsersListSerializer(serializers.Serializer):
''' Serializer: List of Users. '''
users = PKField(many=True, queryset=User.objects.all())
class CstRenameSerializer(serializers.Serializer):
''' Serializer: Constituenta renaming. '''
target = PKField(many=False, queryset=Constituenta.objects.all())
alias = serializers.CharField()
cst_type = serializers.CharField()
def validate(self, attrs):
attrs = super().validate(attrs)
schema = cast(LibraryItem, self.context['schema'])
cst = cast(Constituenta, attrs['target'])
if cst.schema != schema:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title)
})
new_alias = self.initial_data['alias']
if cst.alias == new_alias:
raise serializers.ValidationError({
'alias': msg.renameTrivial(new_alias)
})
if RSForm(schema).constituents().filter(alias=new_alias).exists():
raise serializers.ValidationError({
'alias': msg.aliasTaken(new_alias)
})
return attrs
class CstListSerializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. '''
items = PKField(many=True, queryset=Constituenta.objects.all())
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
if not schema:
return attrs
for item in attrs['items']:
if item.schema != schema:
raise serializers.ValidationError({
f'{item.id}': msg.constituentaNotOwned(schema.title)
})
return attrs
class CstMoveSerializer(CstListSerializer):
''' Serializer: Change constituenta position. '''
move_to = serializers.IntegerField()
class CstSubstituteSerializerBase(serializers.Serializer):
''' Serializer: Basic substitution. '''
original = PKField(many=False, queryset=Constituenta.objects.all())
substitution = PKField(many=False, queryset=Constituenta.objects.all())
transfer_term = serializers.BooleanField(required=False, default=False)
class CstSubstituteSerializer(serializers.Serializer):
''' Serializer: Constituenta substitution. '''
substitutions = serializers.ListField(
child=CstSubstituteSerializerBase(),
min_length=1
)
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
deleted = set()
for item in attrs['substitutions']:
original_cst = cast(Constituenta, item['original'])
substitution_cst = cast(Constituenta, item['substitution'])
if original_cst.pk in deleted:
raise serializers.ValidationError({
f'{original_cst.id}': msg.substituteDouble(original_cst.alias)
})
if original_cst.alias == substitution_cst.alias:
raise serializers.ValidationError({
'alias': msg.substituteTrivial(original_cst.alias)
})
if original_cst.schema != schema:
raise serializers.ValidationError({
'original': msg.constituentaNotOwned(schema.title)
})
if substitution_cst.schema != schema:
raise serializers.ValidationError({
'substitution': msg.constituentaNotOwned(schema.title)
})
deleted.add(original_cst.pk)
return attrs
class InlineSynthesisSerializer(serializers.Serializer):
''' Serializer: Inline synthesis operation input. '''
receiver = PKField(many=False, queryset=LibraryItem.objects.all())
source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore
items = PKField(many=True, queryset=Constituenta.objects.all())
substitutions = serializers.ListField(
child=CstSubstituteSerializerBase()
)
def validate(self, attrs):
user = cast(User, self.context['user'])
schema_in = cast(LibraryItem, attrs['source'])
schema_out = cast(LibraryItem, attrs['receiver'])
if user.is_anonymous or (schema_out.owner != user and not user.is_staff):
raise PermissionDenied({
'message': msg.schemaNotOwned(),
'object_id': schema_in.id
})
constituents = cast(list[Constituenta], attrs['items'])
for cst in constituents:
if cst.schema != schema_in:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema_in.title)
})
deleted = set()
for item in attrs['substitutions']:
original_cst = cast(Constituenta, item['original'])
substitution_cst = cast(Constituenta, item['substitution'])
if original_cst.schema == schema_in:
if original_cst not in constituents:
raise serializers.ValidationError({
f'{original_cst.id}': msg.substitutionNotInList()
})
if substitution_cst.schema != schema_out:
raise serializers.ValidationError({
f'{substitution_cst.id}': msg.constituentaNotOwned(schema_out.title)
})
else:
if substitution_cst not in constituents:
raise serializers.ValidationError({
f'{substitution_cst.id}': msg.substitutionNotInList()
})
if original_cst.schema != schema_out:
raise serializers.ValidationError({
f'{original_cst.id}': msg.constituentaNotOwned(schema_out.title)
})
if original_cst.pk in deleted:
raise serializers.ValidationError({
f'{original_cst.id}': msg.substituteDouble(original_cst.alias)
})
deleted.add(original_cst.pk)
return attrs

View File

@ -0,0 +1,221 @@
''' Serializers for file interaction. '''
from django.db import transaction
from rest_framework import serializers
from .. import messages as msg
from ..models import Constituenta, LibraryItem, RSForm
from ..utils import fix_old_references
_CST_TYPE = 'constituenta'
_TRS_TYPE = 'rsform'
_TRS_VERSION_MIN = 16
_TRS_VERSION = 16
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
class FileSerializer(serializers.Serializer):
''' Serializer: File input. '''
file = serializers.FileField(allow_empty_file=False)
class RSFormUploadSerializer(serializers.Serializer):
''' Upload data for RSForm serializer. '''
file = serializers.FileField()
load_metadata = serializers.BooleanField()
class RSFormTRSSerializer(serializers.Serializer):
''' Serializer: TRS file production and loading for RSForm. '''
def to_representation(self, instance: RSForm) -> dict:
result = self._prepare_json_rsform(instance)
items = instance.constituents().order_by('order')
for cst in items:
result['items'].append(self._prepare_json_constituenta(cst))
return result
@staticmethod
def _prepare_json_rsform(schema: RSForm) -> dict:
return {
'type': _TRS_TYPE,
'title': schema.item.title,
'alias': schema.item.alias,
'comment': schema.item.comment,
'items': [],
'claimed': False,
'selection': [],
'version': _TRS_VERSION,
'versionInfo': _TRS_HEADER
}
@staticmethod
def _prepare_json_constituenta(cst: Constituenta) -> dict:
return {
'entityUID': cst.pk,
'type': _CST_TYPE,
'cstType': cst.cst_type,
'alias': cst.alias,
'convention': cst.convention,
'term': {
'raw': cst.term_raw,
'resolved': cst.term_resolved,
'forms': cst.term_forms
},
'definition': {
'formal': cst.definition_formal,
'text': {
'raw': cst.definition_raw,
'resolved': cst.definition_resolved
},
},
}
def from_versioned_data(self, data: dict) -> dict:
''' Load data from version. '''
result = {
'type': _TRS_TYPE,
'title': data['title'],
'alias': data['alias'],
'comment': data['comment'],
'items': [],
'claimed': False,
'selection': [],
'version': _TRS_VERSION,
'versionInfo': _TRS_HEADER
}
for cst in data['items']:
result['items'].append({
'entityUID': cst['id'],
'type': _CST_TYPE,
'cstType': cst['cst_type'],
'alias': cst['alias'],
'convention': cst['convention'],
'term': {
'raw': cst['term_raw'],
'resolved': cst['term_resolved'],
'forms': cst['term_forms']
},
'definition': {
'formal': cst['definition_formal'],
'text': {
'raw': cst['definition_raw'],
'resolved': cst['definition_resolved']
},
},
})
return result
def to_internal_value(self, data):
result = super().to_internal_value(data)
if 'owner' in data:
result['owner'] = data['owner']
if 'visible' in data:
result['visible'] = data['visible']
if 'read_only' in data:
result['read_only'] = data['read_only']
if 'access_policy' in data:
result['access_policy'] = data['access_policy']
if 'location' in data:
result['location'] = data['location']
result['items'] = data.get('items', [])
if self.context['load_meta']:
result['title'] = data.get('title', 'Без названия')
result['alias'] = data.get('alias', '')
result['comment'] = data.get('comment', '')
if 'id' in data:
result['id'] = data['id']
self.instance = RSForm(LibraryItem.objects.get(pk=result['id']))
return result
def validate(self, attrs: dict):
if 'version' not in self.initial_data \
or self.initial_data['version'] < _TRS_VERSION_MIN \
or self.initial_data['version'] > _TRS_VERSION:
raise serializers.ValidationError({
'version': msg.exteorFileVersionNotSupported()
})
return attrs
@transaction.atomic
def create(self, validated_data: dict) -> RSForm:
self.instance: RSForm = RSForm.create(
owner=validated_data.get('owner', None),
alias=validated_data['alias'],
title=validated_data['title'],
comment=validated_data['comment'],
visible=validated_data['visible'],
read_only=validated_data['read_only'],
access_policy=validated_data['access_policy'],
location=validated_data['location']
)
self.instance.item.save()
order = 1
for cst_data in validated_data['items']:
cst = Constituenta(
alias=cst_data['alias'],
schema=self.instance.item,
order=order,
cst_type=cst_data['cstType'],
)
self._load_cst_texts(cst, cst_data)
cst.save()
order += 1
self.instance.resolve_all_text()
return self.instance
@transaction.atomic
def update(self, instance: RSForm, validated_data) -> RSForm:
if 'alias' in validated_data:
instance.item.alias = validated_data['alias']
if 'title' in validated_data:
instance.item.title = validated_data['title']
if 'comment' in validated_data:
instance.item.comment = validated_data['comment']
order = 1
prev_constituents = instance.constituents()
loaded_ids = set()
for cst_data in validated_data['items']:
uid = int(cst_data['entityUID'])
if prev_constituents.filter(pk=uid).exists():
cst: Constituenta = prev_constituents.get(pk=uid)
cst.order = order
cst.alias = cst_data['alias']
cst.cst_type = cst_data['cstType']
self._load_cst_texts(cst, cst_data)
cst.save()
else:
cst = Constituenta(
alias=cst_data['alias'],
schema=instance.item,
order=order,
cst_type=cst_data['cstType'],
)
self._load_cst_texts(cst, cst_data)
cst.save()
uid = cst.pk
loaded_ids.add(uid)
order += 1
for prev_cst in prev_constituents:
if prev_cst.pk not in loaded_ids:
prev_cst.delete()
instance.resolve_all_text()
instance.item.save()
return instance
@staticmethod
def _load_cst_texts(cst: Constituenta, data: dict):
cst.convention = data.get('convention', '')
if 'definition' in data:
cst.definition_formal = data['definition'].get('formal', '')
if 'text' in data['definition']:
cst.definition_raw = fix_old_references(data['definition']['text'].get('raw', ''))
else:
cst.definition_raw = ''
if 'term' in data:
cst.term_raw = fix_old_references(data['term'].get('raw', ''))
cst.term_forms = data['term'].get('forms', [])
else:
cst.term_raw = ''
cst.term_forms = []

View File

@ -0,0 +1,80 @@
''' Data adapter to interface with pyconcept module. '''
import json
from typing import Optional, Union, cast
import pyconcept
from .. import messages as msg
from ..models import RSForm
class PyConceptAdapter:
''' RSForm adapter for interacting with pyconcept module. '''
def __init__(self, data: Union[RSForm, dict]):
try:
if 'items' in cast(dict, data):
self.data = self._prepare_request_raw(cast(dict, data))
else:
self.data = self._prepare_request(cast(RSForm, data))
except TypeError:
self.data = self._prepare_request(cast(RSForm, data))
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(msg.pyconceptFailure())
return self._checked_data
def _prepare_request(self, schema: RSForm) -> dict:
result: dict = {
'items': []
}
items = 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 _prepare_request_raw(self, data: dict) -> dict:
result: dict = {
'items': []
}
for cst in data['items']:
result['items'].append({
'entityUID': cst['id'],
'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

@ -0,0 +1,29 @@
''' Utility serializers for REST API schema - SHOULD NOT BE ACCESSED DIRECTLY. '''
from rest_framework import serializers
from .data_access import RSFormParseSerializer
class ResultTextResponse(serializers.Serializer):
''' Serializer: Text result of a function call. '''
result = serializers.CharField()
class NewCstResponse(serializers.Serializer):
''' Serializer: Create cst response. '''
new_cst = serializers.IntegerField()
schema = RSFormParseSerializer()
class NewMultiCstResponse(serializers.Serializer):
''' Serializer: Create multiple cst response. '''
cst_list = serializers.ListField(
child=serializers.IntegerField()
)
schema = RSFormParseSerializer()
class NewVersionResponse(serializers.Serializer):
''' Serializer: Create cst response. '''
version = serializers.IntegerField()
schema = RSFormParseSerializer()

View File

@ -0,0 +1,177 @@
''' Utils: base tester class for endpoints. '''
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
from apps.rsform.models import Editor, LibraryItem
from apps.users.models import User
def decl_endpoint(endpoint: str, method: str):
''' Decorator for EndpointTester methods to provide API attributes. '''
def set_endpoint_inner(function):
def wrapper(*args, **kwargs):
if '{' in endpoint:
args[0].endpoint = 'UNRESOLVED'
args[0].endpoint_mask = endpoint
else:
args[0].endpoint_mask = None
args[0].endpoint = endpoint
args[0].method = method
return function(*args, **kwargs)
return wrapper
return set_endpoint_inner
class EndpointTester(APITestCase):
''' Abstract base class for Testing endpoints. '''
def setUp(self):
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
username='UserTest',
email='blank@test.com',
password='password'
)
self.user2 = User.objects.create_user(
username='UserTest2',
email='another@test.com',
password='password'
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def toggle_admin(self, value: bool = True):
self.user.is_staff = value
self.user.save()
def toggle_editor(self, item: LibraryItem, value: bool = True):
if value:
Editor.add(item, self.user)
else:
Editor.remove(item, self.user)
def login(self):
self.client.force_authenticate(user=self.user)
def logout(self):
self.client.logout()
def set_params(self, **kwargs):
''' Given named argument values resolve current endpoint_mask. '''
if self.endpoint_mask and len(kwargs) > 0:
self.endpoint = _resolve_url(self.endpoint_mask, **kwargs)
def get(self, endpoint: str = '', **kwargs):
if endpoint != '':
return self.client.get(endpoint)
else:
self.set_params(**kwargs)
return self.client.get(self.endpoint)
def post(self, data=None, **kwargs):
self.set_params(**kwargs)
if not data is None:
return self.client.post(self.endpoint, data=data, format='json')
else:
return self.client.post(self.endpoint)
def patch(self, data=None, **kwargs):
self.set_params(**kwargs)
if not data is None:
return self.client.patch(self.endpoint, data=data, format='json')
else:
return self.client.patch(self.endpoint)
def put(self, data, **kwargs):
self.set_params(**kwargs)
return self.client.get(self.endpoint, data=data, format='json')
def delete(self, data=None, **kwargs):
self.set_params(**kwargs)
if not data is None:
return self.client.delete(self.endpoint, data=data, format='json')
else:
return self.client.delete(self.endpoint)
def execute(self, data=None, **kwargs):
if self.method == 'get':
return self.get(**kwargs)
if self.method == 'post':
return self.post(data, **kwargs)
if self.method == 'put':
return self.put(data, **kwargs)
if self.method == 'patch':
return self.patch(data, **kwargs)
if self.method == 'delete':
return self.delete(data, **kwargs)
return None
def executeOK(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_200_OK)
return response
def executeCreated(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
return response
def executeAccepted(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
return response
def executeNoContent(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
return response
def executeBadData(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
return response
def executeForbidden(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
return response
def executeNotModified(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_304_NOT_MODIFIED)
return response
def executeNotFound(self, data=None, **kwargs):
response = self.execute(data, **kwargs)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
return response
def _resolve_url(url: str, **kwargs) -> str:
if url == '' or len(kwargs) == 0:
return url
pos_input: int = 0
pos_start: int = 0
pos_end: int = 0
arg_names = set()
output: str = ''
while True:
pos_start = url.find('{', pos_input)
if pos_start == -1:
break
pos_end = url.find('}', pos_start)
if pos_end == -1:
break
name = url[(pos_start + 1): pos_end]
arg_names.add(name)
if not name in kwargs:
raise KeyError(f'Missing argument: {name} | Mask: {url}')
output += url[pos_input: pos_start]
output += str(kwargs[name])
pos_input = pos_end + 1
if pos_input < len(url):
output += url[pos_input: len(url)]
for (key, _) in kwargs.items():
if key not in arg_names:
raise KeyError(f'Unused argument: {name} | Mask: {url}')
return output

View File

@ -0,0 +1,7 @@
''' Tests. '''
from .s_models.t_RSForm import *
from .s_views import *
from .t_graph import *
from .t_imports import *
from .t_serializers import *
from .t_utils import *

View File

@ -0,0 +1,6 @@
''' Tests for REST API. '''
from .t_Constituenta import *
from .t_Editor import *
from .t_LibraryItem import *
from .t_RSForm import *
from .t_Subscription import *

View File

@ -0,0 +1,66 @@
''' Testing models: Constituenta. '''
from django.db.utils import IntegrityError
from django.forms import ValidationError
from django.test import TestCase
from apps.rsform.models import Constituenta, CstType, LibraryItem, LibraryItemType
class TestConstituenta(TestCase):
''' Testing Constituenta model. '''
def setUp(self):
self.schema1 = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test1')
self.schema2 = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test2')
def test_str(self):
testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1, order=1, convention='Test')
self.assertEqual(str(cst), testStr)
def test_url(self):
testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1, order=1, convention='Test')
self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.pk}')
def test_order_not_null(self):
with self.assertRaises(IntegrityError):
Constituenta.objects.create(alias='X1', schema=self.schema1)
def test_order_positive_integer(self):
with self.assertRaises(IntegrityError):
Constituenta.objects.create(alias='X1', schema=self.schema1, order=-1)
def test_order_min_value(self):
with self.assertRaises(ValidationError):
cst = Constituenta.objects.create(alias='X1', schema=self.schema1, order=0)
cst.full_clean()
def test_schema_not_null(self):
with self.assertRaises(IntegrityError):
Constituenta.objects.create(alias='X1', order=1)
def test_create_default(self):
cst = Constituenta.objects.create(
alias='X1',
schema=self.schema1,
order=1
)
self.assertEqual(cst.schema, self.schema1)
self.assertEqual(cst.order, 1)
self.assertEqual(cst.alias, 'X1')
self.assertEqual(cst.cst_type, CstType.BASE)
self.assertEqual(cst.convention, '')
self.assertEqual(cst.definition_formal, '')
self.assertEqual(cst.term_raw, '')
self.assertEqual(cst.term_resolved, '')
self.assertEqual(cst.term_forms, [])
self.assertEqual(cst.definition_resolved, '')
self.assertEqual(cst.definition_raw, '')

View File

@ -0,0 +1,76 @@
''' Testing models: Editor. '''
from django.test import TestCase
from apps.rsform.models import Editor, LibraryItem, LibraryItemType, User
class TestEditor(TestCase):
''' Testing Editor model. '''
def setUp(self):
self.user1 = User.objects.create(username='User1')
self.user2 = User.objects.create(username='User2')
self.item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test',
alias='КС1',
owner=self.user1
)
def test_default(self):
editors = list(Editor.objects.filter(item=self.item))
self.assertEqual(len(editors), 0)
def test_str(self):
testStr = 'КС1: User2'
item = Editor.objects.create(
editor=self.user2,
item=self.item
)
self.assertEqual(str(item), testStr)
def test_add_editor(self):
self.assertTrue(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(self.user1 in self.item.editors())
self.assertFalse(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(Editor.add(self.item, self.user2))
self.assertEqual(len(self.item.editors()), 2)
self.assertTrue(self.user1 in self.item.editors())
self.assertTrue(self.user2 in self.item.editors())
self.user1.delete()
self.assertEqual(len(self.item.editors()), 1)
def test_remove_editor(self):
self.assertFalse(Editor.remove(self.item, self.user1))
Editor.add(self.item, self.user1)
Editor.add(self.item, self.user2)
self.assertEqual(len(self.item.editors()), 2)
self.assertTrue(Editor.remove(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(self.user2 in self.item.editors())
self.assertFalse(Editor.remove(self.item, self.user1))
def test_set_editors(self):
Editor.set(self.item, [self.user1])
self.assertEqual(self.item.editors(), [self.user1])
Editor.set(self.item, [self.user1, self.user1])
self.assertEqual(self.item.editors(), [self.user1])
Editor.set(self.item, [])
self.assertEqual(self.item.editors(), [])
Editor.set(self.item, [self.user1, self.user2])
self.assertEqual(set(self.item.editors()), set([self.user1, self.user2]))

View File

@ -0,0 +1,103 @@
''' Testing models: LibraryItem. '''
from django.test import TestCase
from apps.rsform.models import (
AccessPolicy,
LibraryItem,
LibraryItemType,
LocationHead,
Subscription,
User,
validate_location
)
class TestLibraryItem(TestCase):
''' Testing LibraryItem model. '''
def setUp(self):
self.user1 = User.objects.create(username='User1')
self.user2 = User.objects.create(username='User2')
def test_str(self):
testStr = 'Test123'
item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Title',
owner=self.user1,
alias=testStr
)
self.assertEqual(str(item), testStr)
def test_url(self):
testStr = 'Test123'
item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title=testStr,
owner=self.user1,
alias='КС1'
)
self.assertEqual(item.get_absolute_url(), f'/api/library/{item.pk}')
def test_create_default(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertIsNone(item.owner)
self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, '')
self.assertEqual(item.comment, '')
self.assertEqual(item.visible, True)
self.assertEqual(item.read_only, False)
self.assertEqual(item.access_policy, AccessPolicy.PUBLIC)
self.assertEqual(item.location, LocationHead.USER)
def test_create(self):
item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test',
owner=self.user1,
alias='KS1',
comment='Test comment',
location=LocationHead.COMMON
)
self.assertEqual(item.owner, self.user1)
self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, 'KS1')
self.assertEqual(item.comment, 'Test comment')
self.assertEqual(item.location, LocationHead.COMMON)
self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists())
class TestLocation(TestCase):
''' Testing Location model. '''
def test_validate_location(self):
self.assertFalse(validate_location(''))
self.assertFalse(validate_location('/A'))
self.assertFalse(validate_location('U/U'))
self.assertFalse(validate_location('/U/'))
self.assertFalse(validate_location('/U/user@mail'))
self.assertFalse(validate_location('/U/u\\asdf'))
self.assertFalse(validate_location('/U/ asdf'))
self.assertFalse(validate_location('/User'))
self.assertFalse(validate_location('//'))
self.assertFalse(validate_location('/S/1/'))
self.assertFalse(validate_location('/S/1 '))
self.assertFalse(validate_location('/S/1/2 /3'))
self.assertFalse(validate_location('/S/-'))
self.assertFalse(validate_location('/S/1-'))
self.assertTrue(validate_location('/P'))
self.assertTrue(validate_location('/L'))
self.assertTrue(validate_location('/U'))
self.assertTrue(validate_location('/S'))
self.assertTrue(validate_location('/S/1'))
self.assertTrue(validate_location('/S/1-2'))
self.assertTrue(validate_location('/S/20210101 asdf-a/2'))
self.assertTrue(validate_location('/S/12'))
self.assertTrue(validate_location('/S/12/3'))
self.assertTrue(validate_location('/S/Вася шофер'))
self.assertTrue(validate_location('/S/1/!asdf/тест тест'))

View File

@ -0,0 +1,364 @@
''' Testing models: api_RSForm. '''
from django.forms import ValidationError
from django.test import TestCase
from apps.rsform.models import Constituenta, CstType, RSForm, User
class TestRSForm(TestCase):
''' Testing RSForm wrapper. '''
def setUp(self):
self.user1 = User.objects.create(username='User1')
self.user2 = User.objects.create(username='User2')
self.schema = RSForm.create(title='Test')
self.assertNotEqual(self.user1, self.user2)
def test_constituents(self):
schema1 = RSForm.create(title='Test1')
schema2 = RSForm.create(title='Test2')
self.assertFalse(schema1.constituents().exists())
self.assertFalse(schema2.constituents().exists())
Constituenta.objects.create(alias='X1', schema=schema1.item, order=1)
Constituenta.objects.create(alias='X2', schema=schema1.item, order=2)
self.assertTrue(schema1.constituents().exists())
self.assertFalse(schema2.constituents().exists())
self.assertEqual(schema1.constituents().count(), 2)
def test_get_max_index(self):
schema1 = RSForm.create(title='Test1')
Constituenta.objects.create(alias='X1', schema=schema1.item, order=1)
Constituenta.objects.create(alias='D2', cst_type=CstType.TERM, schema=schema1.item, order=2)
self.assertEqual(schema1.get_max_index(CstType.BASE), 1)
self.assertEqual(schema1.get_max_index(CstType.TERM), 2)
self.assertEqual(schema1.get_max_index(CstType.AXIOM), 0)
def test_insert_at(self):
schema = RSForm.create(title='Test')
x1 = schema.insert_new('X1')
self.assertEqual(x1.order, 1)
self.assertEqual(x1.schema, schema.item)
x2 = schema.insert_new('X2', position=1)
x1.refresh_from_db()
self.assertEqual(x2.order, 1)
self.assertEqual(x2.schema, schema.item)
self.assertEqual(x1.order, 2)
x3 = schema.insert_new('X3', position=4)
x2.refresh_from_db()
x1.refresh_from_db()
self.assertEqual(x3.order, 3)
self.assertEqual(x3.schema, schema.item)
self.assertEqual(x2.order, 1)
self.assertEqual(x1.order, 2)
x4 = schema.insert_new('X4', position=3)
x3.refresh_from_db()
x2.refresh_from_db()
x1.refresh_from_db()
self.assertEqual(x4.order, 3)
self.assertEqual(x4.schema, schema.item)
self.assertEqual(x3.order, 4)
self.assertEqual(x2.order, 1)
self.assertEqual(x1.order, 2)
def test_insert_at_invalid_position(self):
with self.assertRaises(ValidationError):
self.schema.insert_new('X5', position=0)
def test_insert_at_invalid_alias(self):
self.schema.insert_new('X1')
with self.assertRaises(ValidationError):
self.schema.insert_new('X1')
def test_insert_at_reorder(self):
self.schema.insert_new('X1')
d1 = self.schema.insert_new('D1')
d2 = self.schema.insert_new('D2', position=1)
d1.refresh_from_db()
self.assertEqual(d1.order, 3)
self.assertEqual(d2.order, 1)
x2 = self.schema.insert_new('X2', position=4)
self.assertEqual(x2.order, 4)
def test_insert_last(self):
x1 = self.schema.insert_new('X1')
self.assertEqual(x1.order, 1)
self.assertEqual(x1.schema, self.schema.item)
x2 = self.schema.insert_new('X2')
self.assertEqual(x2.order, 2)
self.assertEqual(x2.schema, self.schema.item)
self.assertEqual(x1.order, 1)
def test_create_cst_resolve(self):
x1 = self.schema.insert_new(
alias='X1',
term_raw='@{X2|datv}',
definition_raw='@{X1|datv} @{X2|datv}'
)
x2 = self.schema.create_cst({
'alias': 'X2',
'cst_type': CstType.BASE,
'term_raw': 'слон',
'definition_raw': '@{X1|plur} @{X2|plur}'
})
x1.refresh_from_db()
self.assertEqual(x1.term_resolved, 'слону')
self.assertEqual(x1.definition_resolved, 'слону слону')
self.assertEqual(x2.term_resolved, 'слон')
self.assertEqual(x2.definition_resolved, 'слонам слоны')
def test_insert_copy(self):
x1 = self.schema.insert_new(
alias='X10',
convention='Test'
)
s1 = self.schema.insert_new(
alias='S11',
definition_formal=x1.alias,
definition_raw='@{X10|plur}'
)
result = self.schema.insert_copy([s1, x1], 2)
self.assertEqual(len(result), 2)
s1.refresh_from_db()
self.assertEqual(s1.order, 4)
x2 = result[1]
self.assertEqual(x2.order, 3)
self.assertEqual(x2.alias, 'X11')
self.assertEqual(x2.cst_type, CstType.BASE)
self.assertEqual(x2.convention, x1.convention)
s2 = result[0]
self.assertEqual(s2.order, 2)
self.assertEqual(s2.alias, 'S12')
self.assertEqual(s2.cst_type, CstType.STRUCTURED)
self.assertEqual(s2.definition_formal, x2.alias)
self.assertEqual(s2.definition_raw, '@{X11|plur}')
def test_apply_mapping(self):
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X11')
d1 = self.schema.insert_new(
alias='D1',
definition_formal='X1 = X11 = X2',
definition_raw='@{X11|sing}',
convention='X1',
term_raw='@{X1|plur}'
)
self.schema.apply_mapping({x1.alias: 'X3', x2.alias: 'X4'})
d1.refresh_from_db()
self.assertEqual(d1.definition_formal, 'X3 = X4 = X2', msg='Map IDs in expression')
self.assertEqual(d1.definition_raw, '@{X4|sing}', msg='Map IDs in definition')
self.assertEqual(d1.convention, 'X3', msg='Map IDs in convention')
self.assertEqual(d1.term_raw, '@{X3|plur}', msg='Map IDs in term')
self.assertEqual(d1.term_resolved, '', msg='Do not run resolve on mapping')
self.assertEqual(d1.definition_resolved, '', msg='Do not run resolve on mapping')
def test_substitute(self):
x1 = self.schema.insert_new(
alias='X1',
term_raw='Test'
)
x2 = self.schema.insert_new(
alias='X2',
term_raw='Test2'
)
d1 = self.schema.insert_new(
alias='D1',
definition_formal=x1.alias
)
self.schema.substitute(x1, x2, True)
x2.refresh_from_db()
d1.refresh_from_db()
self.assertEqual(self.schema.constituents().count(), 2)
self.assertEqual(x2.term_raw, 'Test')
self.assertEqual(d1.definition_formal, x2.alias)
def test_move_cst(self):
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X2')
d1 = self.schema.insert_new('D1')
d2 = self.schema.insert_new('D2')
self.schema.move_cst([x2, d2], 1)
x1.refresh_from_db()
x2.refresh_from_db()
d1.refresh_from_db()
d2.refresh_from_db()
self.assertEqual(x1.order, 3)
self.assertEqual(x2.order, 1)
self.assertEqual(d1.order, 4)
self.assertEqual(d2.order, 2)
def test_move_cst_down(self):
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X2')
self.schema.move_cst([x1], 2)
x1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(x1.order, 2)
self.assertEqual(x2.order, 1)
def test_restore_order(self):
d2 = self.schema.insert_new(
alias='D2',
definition_formal=r'D{ξ∈S1 | 1=1}',
)
d1 = self.schema.insert_new(
alias='D1',
definition_formal=r'Pr1(S1)\X1',
)
x1 = self.schema.insert_new('X1')
x2 = self.schema.insert_new('X2')
s1 = self.schema.insert_new(
alias='S1',
definition_formal='(X1×X1)'
)
c1 = self.schema.insert_new('C1')
s2 = self.schema.insert_new(
alias='S2',
definition_formal='(X2×D1)'
)
a1 = self.schema.insert_new(
alias='A1',
definition_formal=r'D3=∅',
)
d3 = self.schema.insert_new(
alias='D3',
definition_formal=r'Pr2(S2)',
)
f1 = self.schema.insert_new(
alias='F1',
definition_formal=r'[α∈ℬ(X1)] D{σ∈S1 | α⊆pr1(σ)}',
)
d4 = self.schema.insert_new(
alias='D4',
definition_formal=r'Pr2(D3)',
)
f2 = self.schema.insert_new(
alias='F2',
definition_formal=r'[α∈ℬ(X1)] X1\α',
)
self.schema.restore_order()
x1.refresh_from_db()
x2.refresh_from_db()
c1.refresh_from_db()
s1.refresh_from_db()
s2.refresh_from_db()
d1.refresh_from_db()
d2.refresh_from_db()
d3.refresh_from_db()
d4.refresh_from_db()
f1.refresh_from_db()
f2.refresh_from_db()
a1.refresh_from_db()
self.assertEqual(x1.order, 1)
self.assertEqual(x2.order, 2)
self.assertEqual(c1.order, 3)
self.assertEqual(s1.order, 4)
self.assertEqual(d1.order, 5)
self.assertEqual(s2.order, 6)
self.assertEqual(d3.order, 7)
self.assertEqual(a1.order, 8)
self.assertEqual(d4.order, 9)
self.assertEqual(d2.order, 10)
self.assertEqual(f1.order, 11)
self.assertEqual(f2.order, 12)
def test_reset_aliases(self):
x1 = self.schema.insert_new(
alias='X11',
term_raw='человек',
term_resolved='человек'
)
x2 = self.schema.insert_new('X21')
d1 = self.schema.insert_new(
alias='D11',
convention='D11 - cool',
definition_formal='X21=X21',
term_raw='@{X21|sing}',
definition_raw='@{X11|datv}',
definition_resolved='test'
)
self.schema.reset_aliases()
x1.refresh_from_db()
x2.refresh_from_db()
d1.refresh_from_db()
self.assertEqual(x1.alias, 'X1')
self.assertEqual(x2.alias, 'X2')
self.assertEqual(d1.alias, 'D1')
self.assertEqual(d1.convention, 'D1 - cool')
self.assertEqual(d1.term_raw, '@{X2|sing}')
self.assertEqual(d1.definition_raw, '@{X1|datv}')
self.assertEqual(d1.definition_resolved, 'test')
def test_on_term_change(self):
x1 = self.schema.insert_new(
alias='X1',
term_raw='человек',
term_resolved='человек',
definition_raw='одному @{X1|datv}',
definition_resolved='одному человеку',
)
x2 = self.schema.insert_new(
alias='X2',
term_raw='сильный @{X1|sing}',
term_resolved='сильный человек',
definition_raw=x1.definition_raw,
definition_resolved=x1.definition_resolved
)
x3 = self.schema.insert_new(
alias='X3',
definition_raw=x1.definition_raw,
definition_resolved=x1.definition_resolved
)
d1 = self.schema.insert_new(
alias='D1',
definition_raw='очень @{X2|sing}',
definition_resolved='очень сильный человек'
)
x1.term_raw = 'слон'
x1.term_resolved = 'слон'
x1.save()
self.schema.on_term_change([x1.pk])
x1.refresh_from_db()
x2.refresh_from_db()
x3.refresh_from_db()
d1.refresh_from_db()
self.assertEqual(x1.term_raw, 'слон')
self.assertEqual(x1.term_resolved, 'слон')
self.assertEqual(x1.definition_resolved, 'одному слону')
self.assertEqual(x2.definition_resolved, x1.definition_resolved)
self.assertEqual(x3.definition_resolved, x1.definition_resolved)
self.assertEqual(d1.definition_resolved, 'очень сильный слон')

View File

@ -0,0 +1,68 @@
''' Testing models: Subscription. '''
from django.test import TestCase
from apps.rsform.models import LibraryItem, LibraryItemType, Subscription, User
class TestSubscription(TestCase):
''' Testing Subscription model. '''
def setUp(self):
self.user1 = User.objects.create(username='User1')
self.user2 = User.objects.create(username='User2')
self.item = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test',
alias='КС1',
owner=self.user1
)
def test_default(self):
subs = list(Subscription.objects.filter(item=self.item))
self.assertEqual(len(subs), 1)
self.assertEqual(subs[0].item, self.item)
self.assertEqual(subs[0].user, self.user1)
def test_str(self):
testStr = 'User2 -> КС1'
item = Subscription.objects.create(
user=self.user2,
item=self.item
)
self.assertEqual(str(item), testStr)
def test_subscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertEqual(len(item.subscribers()), 0)
self.assertTrue(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user1 in item.subscribers())
self.assertFalse(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(Subscription.subscribe(self.user2, item))
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(self.user1 in item.subscribers())
self.assertTrue(self.user2 in item.subscribers())
self.user1.delete()
self.assertEqual(len(item.subscribers()), 1)
def test_unsubscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertFalse(Subscription.unsubscribe(self.user1, item))
Subscription.subscribe(self.user1, item)
Subscription.subscribe(self.user2, item)
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(Subscription.unsubscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user2 in item.subscribers())
self.assertFalse(Subscription.unsubscribe(self.user1, item))

View File

@ -0,0 +1,9 @@
''' Tests for REST API. '''
from .t_library import *
from .t_constituents import *
from .t_operations import *
from .t_rsforms import *
from .t_versions import *
from .t_cctext import *
from .t_rslang import *

View File

@ -0,0 +1,33 @@
''' Testing views '''
from cctext import split_grams
from ..EndpointTester import EndpointTester, decl_endpoint
class TestNaturalLanguageViews(EndpointTester):
''' Test natural language endpoints. '''
def _assert_tags(self, actual: str, expected: str):
self.assertEqual(set(split_grams(actual)), set(split_grams(expected)))
@decl_endpoint(endpoint='/api/cctext/parse', method='post')
def test_parse_text(self):
data = {'text': 'синим слонам'}
response = self.executeOK(data)
self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc')
@decl_endpoint(endpoint='/api/cctext/inflect', method='post')
def test_inflect(self):
data = {'text': 'синий слон', 'grams': 'plur,datv'}
response = self.executeOK(data)
self.assertEqual(response.data['result'], 'синим слонам')
@decl_endpoint(endpoint='/api/cctext/generate-lexeme', method='post')
def test_generate_lexeme(self):
data = {'text': 'синий слон'}
response = self.executeOK(data)
self.assertEqual(len(response.data['items']), 12)
self.assertEqual(response.data['items'][0]['text'], 'синий слон')

View File

@ -0,0 +1,103 @@
''' Testing API: Constituents. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from ..EndpointTester import EndpointTester, decl_endpoint
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.rsform_owned.item,
order=1,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.rsform_unowned.item,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.rsform_owned.item,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/constituents/{item}', method='get')
def test_retrieve(self):
self.executeNotFound(item=self.invalid_cst)
response = self.executeOK(item=self.cst1.pk)
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['convention'], self.cst1.convention)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_partial_update(self):
data = {'convention': 'tt'}
self.executeForbidden(data, item=self.cst2.pk)
self.logout()
self.executeForbidden(data, item=self.cst1.pk)
self.login()
response = self.executeOK(data, item=self.cst1.pk)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data, item=self.cst1.pk)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_no_refs(self):
data = {
'term_raw': 'New term',
'definition_raw': 'New def'
}
response = self.executeOK(data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(response.data['definition_resolved'], 'New def')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_refs(self):
data = {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
response = self.executeOK(data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1')
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_readonly_cst_fields(self):
data = {'alias': 'X33', 'order': 10}
response = self.executeOK(data, item=self.cst1.pk)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['order'], self.cst1.order)

View File

@ -0,0 +1,403 @@
''' Testing API: Library. '''
from rest_framework import status
from apps.rsform.models import (
AccessPolicy,
Editor,
LibraryItem,
LibraryItemType,
LibraryTemplate,
LocationHead,
RSForm,
Subscription
)
from ..EndpointTester import EndpointTester, decl_endpoint
from ..testing_utils import response_contains
class TestLibraryViewset(EndpointTester):
''' Testing Library view. '''
def setUp(self):
super().setUp()
self.owned = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test',
alias='T1',
owner=self.user
)
self.schema = RSForm(self.owned)
self.unowned = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test2',
alias='T2'
)
self.common = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
title='Test3',
alias='T3',
location=LocationHead.COMMON
)
self.invalid_user = 1337 + self.user2.pk
self.invalid_item = 1337 + self.common.pk
@decl_endpoint('/api/library', method='post')
def test_create(self):
data = {
'title': 'Title',
'alias': 'alias',
}
self.executeBadData(data)
data = {
'item_type': LibraryItemType.OPERATIONS_SCHEMA,
'title': 'Title',
'alias': 'alias',
'access_policy': AccessPolicy.PROTECTED,
'visible': False,
'read_only': True
}
response = self.executeCreated(data)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['item_type'], data['item_type'])
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], data['alias'])
self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only'])
self.logout()
data = {'title': 'Title2'}
self.executeForbidden(data)
@decl_endpoint('/api/library/{item}', method='patch')
def test_update(self):
data = {'id': self.unowned.pk, 'title': 'New Title'}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_editor(self.unowned, True)
response = self.executeOK(data, item=self.unowned.pk)
self.assertEqual(response.data['title'], data['title'])
self.unowned.access_policy = AccessPolicy.PRIVATE
self.unowned.save()
self.executeForbidden(data, item=self.unowned.pk)
data = {'id': self.owned.pk, 'title': 'New Title'}
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], self.owned.alias)
data = {
'id': self.owned.pk,
'title': 'Another Title',
'owner': self.user2.pk,
'access_policy': AccessPolicy.PROTECTED,
'location': LocationHead.LIBRARY
}
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['owner'], self.owned.owner.pk)
self.assertEqual(response.data['access_policy'], self.owned.access_policy)
self.assertEqual(response.data['location'], self.owned.location)
self.assertNotEqual(response.data['location'], LocationHead.LIBRARY)
@decl_endpoint('/api/library/{item}/set-owner', method='patch')
def test_set_owner(self):
time_update = self.owned.time_update
data = {'user': self.user.pk}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user)
data = {'user': self.user2.pk}
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user2)
self.assertEqual(self.owned.time_update, time_update)
self.executeForbidden(data, item=self.owned.pk)
self.toggle_admin(True)
data = {'user': self.user.pk}
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user)
@decl_endpoint('/api/library/{item}/set-access-policy', method='patch')
def test_set_access_policy(self):
time_update = self.owned.time_update
data = {'access_policy': 'invalid'}
self.executeBadData(data, item=self.owned.pk)
data = {'access_policy': AccessPolicy.PRIVATE}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.access_policy, data['access_policy'])
self.toggle_editor(self.unowned, True)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.access_policy, data['access_policy'])
@decl_endpoint('/api/library/{item}/set-location', method='patch')
def test_set_location(self):
time_update = self.owned.time_update
data = {'location': 'invalid'}
self.executeBadData(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
data = {'location': LocationHead.LIBRARY}
self.executeForbidden(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.toggle_editor(self.unowned, True)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
data = {'location': LocationHead.LIBRARY}
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/{item}/editors-add', method='patch')
def test_add_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data)
self.assertEqual(self.owned.editors(), [self.user])
data = {'user': self.user2.pk}
self.executeOK(data)
self.assertEqual(set(self.owned.editors()), set([self.user, self.user2]))
@decl_endpoint('/api/library/{item}/editors-remove', method='patch')
def test_remove_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
self.executeOK(data)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
Editor.add(item=self.owned, user=self.user2)
data = {'user': self.user2.pk}
self.executeOK(data)
self.assertEqual(self.owned.editors(), [self.user])
@decl_endpoint('/api/library/{item}/editors-set', method='patch')
def test_set_editors(self):
time_update = self.owned.time_update
data = {'users': [self.invalid_user]}
self.executeBadData(data, item=self.owned.pk)
data = {'users': [self.user.pk]}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data)
self.assertEqual(self.owned.editors(), [self.user])
data = {'users': [self.user2.pk]}
self.executeOK(data)
self.assertEqual(self.owned.editors(), [self.user2])
data = {'users': []}
self.executeOK(data)
self.assertEqual(self.owned.editors(), [])
data = {'users': [self.user2.pk, self.user.pk]}
self.executeOK(data)
self.assertEqual(set(self.owned.editors()), set([self.user2, self.user]))
@decl_endpoint('/api/library/{item}', method='delete')
def test_destroy(self):
response = self.execute(item=self.owned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
self.executeForbidden(item=self.unowned.pk)
self.toggle_admin(True)
response = self.execute(item=self.unowned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
@decl_endpoint('/api/library/active', method='get')
def test_retrieve_common(self):
response = self.executeOK()
self.assertTrue(response_contains(response, self.common))
self.assertFalse(response_contains(response, self.unowned))
self.assertTrue(response_contains(response, self.owned))
self.logout()
response = self.executeOK()
self.assertTrue(response_contains(response, self.common))
self.assertFalse(response_contains(response, self.unowned))
self.assertFalse(response_contains(response, self.owned))
@decl_endpoint('/api/library', method='get')
def test_library_get(self):
non_schema = LibraryItem.objects.create(
item_type=LibraryItemType.OPERATIONS_SCHEMA,
title='Test4'
)
response = self.executeOK()
self.assertTrue(response_contains(response, non_schema))
self.assertTrue(response_contains(response, self.unowned))
self.assertTrue(response_contains(response, self.owned))
@decl_endpoint('/api/library/all', method='get')
def test_retrieve_all(self):
self.toggle_admin(False)
self.executeForbidden()
self.toggle_admin(True)
response = self.executeOK()
self.assertTrue(response_contains(response, self.common))
self.assertTrue(response_contains(response, self.unowned))
self.assertTrue(response_contains(response, self.owned))
self.logout()
self.executeForbidden()
@decl_endpoint('/api/library/active', method='get')
def test_retrieve_subscribed(self):
response = self.executeOK()
self.assertFalse(response_contains(response, self.unowned))
Subscription.subscribe(user=self.user, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.owned)
response = self.executeOK()
self.assertTrue(response_contains(response, self.unowned))
self.assertEqual(len(response.data), 3)
@decl_endpoint('/api/library/{item}/subscribe', method='post')
def test_subscriptions(self):
self.executeNotFound(item=self.invalid_item)
response = self.client.delete(f'/api/library/{self.unowned.pk}/unsubscribe')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(self.user in self.unowned.subscribers())
response = self.executeOK(item=self.unowned.pk)
self.assertTrue(self.user in self.unowned.subscribers())
response = self.executeOK(item=self.unowned.pk)
self.assertTrue(self.user in self.unowned.subscribers())
response = self.client.delete(f'/api/library/{self.unowned.pk}/unsubscribe')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(self.user in self.unowned.subscribers())
@decl_endpoint('/api/library/templates', method='get')
def test_retrieve_templates(self):
response = self.executeOK()
self.assertFalse(response_contains(response, self.common))
self.assertFalse(response_contains(response, self.unowned))
self.assertFalse(response_contains(response, self.owned))
LibraryTemplate.objects.create(lib_source=self.unowned)
response = self.executeOK()
self.assertFalse(response_contains(response, self.common))
self.assertTrue(response_contains(response, self.unowned))
self.assertFalse(response_contains(response, self.owned))
@decl_endpoint('/api/library/{item}/clone', method='post')
def test_clone_rsform(self):
x12 = self.schema.insert_new(
alias='X12',
term_raw='человек',
term_resolved='человек'
)
d2 = self.schema.insert_new(
alias='D2',
term_raw='@{X12|plur}',
term_resolved='люди'
)
data = {'title': 'Title1337'}
self.executeNotFound(data, item=self.invalid_item)
self.executeCreated(data, item=self.unowned.pk)
data = {'title': 'Title1338'}
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 2)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)
self.assertEqual(response.data['items'][1]['term_raw'], d2.term_raw)
self.assertEqual(response.data['items'][1]['term_resolved'], d2.term_resolved)
data = {'title': 'Title1340', 'items': []}
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 0)
data = {'title': 'Title1341', 'items': [x12.pk]}
response = self.executeCreated(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(len(response.data['items']), 1)
self.assertEqual(response.data['items'][0]['alias'], x12.alias)
self.assertEqual(response.data['items'][0]['term_raw'], x12.term_raw)
self.assertEqual(response.data['items'][0]['term_resolved'], x12.term_resolved)

View File

@ -0,0 +1,83 @@
''' Testing API: Operations. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from ..EndpointTester import EndpointTester, decl_endpoint
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
@decl_endpoint('/api/operations/inline-synthesis', method='patch')
def setUp(self):
super().setUp()
self.schema1 = RSForm.create(title='Test1', alias='T1', owner=self.user)
self.schema2 = RSForm.create(title='Test2', alias='T2', owner=self.user)
self.unowned = RSForm.create(title='Test3', alias='T3')
def test_inline_synthesis_inputs(self):
invalid_id = 1338
data = {
'receiver': self.unowned.item.pk,
'source': self.schema1.item.pk,
'items': [],
'substitutions': []
}
self.executeForbidden(data)
data['receiver'] = invalid_id
self.executeBadData(data)
data['receiver'] = self.schema1.item.pk
data['source'] = invalid_id
self.executeBadData(data)
data['source'] = self.schema1.item.pk
self.executeOK(data)
data['items'] = [invalid_id]
self.executeBadData(data)
def test_inline_synthesis(self):
ks1_x1 = self.schema1.insert_new('X1', term_raw='KS1X1') # -> delete
ks1_x2 = self.schema1.insert_new('X2', term_raw='KS1X2') # -> X2
ks1_s1 = self.schema1.insert_new('S1', definition_formal='X2', term_raw='KS1S1') # -> S1
ks1_d1 = self.schema1.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D1
ks2_x1 = self.schema2.insert_new('X1', term_raw='KS2X1') # -> delete
ks2_x2 = self.schema2.insert_new('X2', term_raw='KS2X2') # -> X4
ks2_s1 = self.schema2.insert_new('S1', definition_formal='X2×X2', term_raw='KS2S1') # -> S2
ks2_d1 = self.schema2.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D2
ks2_a1 = self.schema2.insert_new('A1', definition_formal='1=1') # -> not included in items
data = {
'receiver': self.schema1.item.pk,
'source': self.schema2.item.pk,
'items': [ks2_x1.pk, ks2_x2.pk, ks2_s1.pk, ks2_d1.pk],
'substitutions': [
{
'original': ks1_x1.pk,
'substitution': ks2_s1.pk,
'transfer_term': False
},
{
'original': ks2_x1.pk,
'substitution': ks1_s1.pk,
'transfer_term': True
}
]
}
response = self.executeOK(data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['X2']['term_raw'], ks1_x2.term_raw)
self.assertEqual(result['X2']['order'], 1)
self.assertEqual(result['X4']['term_raw'], ks2_x2.term_raw)
self.assertEqual(result['X4']['order'], 2)
self.assertEqual(result['S1']['term_raw'], ks2_x1.term_raw)
self.assertEqual(result['S2']['term_raw'], ks2_s1.term_raw)
self.assertEqual(result['S1']['definition_formal'], 'X2')
self.assertEqual(result['S2']['definition_formal'], 'X4×X4')
self.assertEqual(result['D1']['definition_formal'], r'S1\S2\X2')
self.assertEqual(result['D2']['definition_formal'], r'S2\S1\X4')

View File

@ -0,0 +1,518 @@
''' Testing API: RSForms. '''
import io
import os
from zipfile import ZipFile
from cctext import ReferenceType
from rest_framework import status
from apps.rsform.models import (
AccessPolicy,
Constituenta,
CstType,
LibraryItem,
LibraryItemType,
LocationHead,
RSForm
)
from ..EndpointTester import EndpointTester, decl_endpoint
from ..testing_utils import response_contains
class TestRSFormViewset(EndpointTester):
''' Testing RSForm view. '''
def setUp(self):
super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.owned_id = self.owned.item.pk
self.unowned = RSForm.create(title='Test2', alias='T2')
self.unowned_id = self.unowned.item.pk
self.private = RSForm.create(title='Test2', alias='T2', access_policy=AccessPolicy.PRIVATE)
self.private_id = self.private.item.pk
@decl_endpoint('/api/rsforms/create-detailed', method='post')
def test_create_rsform_file(self):
work_dir = os.path.dirname(os.path.abspath(__file__))
data = {
'title': 'Test123',
'comment': '123',
'alias': 'ks1',
'location': LocationHead.PROJECTS,
'access_policy': AccessPolicy.PROTECTED,
'visible': False
}
self.executeBadData(data)
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data['file'] = file
response = self.client.post(self.endpoint, data=data, format='multipart')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], data['alias'])
self.assertEqual(response.data['comment'], data['comment'])
@decl_endpoint('/api/rsforms', method='get')
def test_list_rsforms(self):
non_schema = LibraryItem.objects.create(
item_type=LibraryItemType.OPERATIONS_SCHEMA,
title='Test3'
)
response = self.executeOK()
self.assertFalse(response_contains(response, non_schema))
self.assertTrue(response_contains(response, self.unowned.item))
self.assertTrue(response_contains(response, self.owned.item))
@decl_endpoint('/api/rsforms/{item}/contents', method='get')
def test_contents(self):
response = self.executeOK(item=self.owned_id)
self.assertEqual(response.data['owner'], self.owned.item.owner.pk)
self.assertEqual(response.data['title'], self.owned.item.title)
self.assertEqual(response.data['alias'], self.owned.item.alias)
self.assertEqual(response.data['location'], self.owned.item.location)
self.assertEqual(response.data['access_policy'], self.owned.item.access_policy)
self.assertEqual(response.data['visible'], self.owned.item.visible)
@decl_endpoint('/api/rsforms/{item}/details', method='get')
def test_details(self):
x1 = self.owned.insert_new(
alias='X1',
term_raw='человек',
term_resolved='человек'
)
x2 = self.owned.insert_new(
alias='X2',
term_raw='@{X1|plur}',
term_resolved='люди'
)
response = self.executeOK(item=self.owned_id)
self.assertEqual(response.data['owner'], self.owned.item.owner.pk)
self.assertEqual(response.data['title'], self.owned.item.title)
self.assertEqual(response.data['alias'], self.owned.item.alias)
self.assertEqual(response.data['location'], self.owned.item.location)
self.assertEqual(response.data['access_policy'], self.owned.item.access_policy)
self.assertEqual(response.data['visible'], self.owned.item.visible)
self.assertEqual(len(response.data['items']), 2)
self.assertEqual(response.data['items'][0]['id'], x1.pk)
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'][1]['id'], x2.pk)
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])
self.assertEqual(response.data['editors'], [])
self.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id)
self.logout()
self.executeOK(item=self.owned_id)
self.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id)
@decl_endpoint('/api/rsforms/{item}/check', method='post')
def test_check(self):
self.owned.insert_new('X1')
data = {'expression': 'X1=X1'}
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[=[X1][X1]]')
self.assertEqual(response.data['typification'], 'LOGIC')
self.assertEqual(response.data['valueClass'], 'value')
self.executeOK(data, item=self.unowned_id)
@decl_endpoint('/api/rsforms/{item}/resolve', method='post')
def test_resolve(self):
x1 = self.owned.insert_new(
alias='X1',
term_resolved='синий слон'
)
data = {'text': '@{1|редкий} @{X1|plur,datv}'}
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам')
self.assertEqual(len(response.data['refs']), 2)
self.assertEqual(response.data['refs'][0]['type'], ReferenceType.syntactic.value)
self.assertEqual(response.data['refs'][0]['resolved'], 'редким')
self.assertEqual(response.data['refs'][0]['data']['offset'], 1)
self.assertEqual(response.data['refs'][0]['data']['nominal'], 'редкий')
self.assertEqual(response.data['refs'][0]['pos_input']['start'], 0)
self.assertEqual(response.data['refs'][0]['pos_input']['finish'], 11)
self.assertEqual(response.data['refs'][0]['pos_output']['start'], 0)
self.assertEqual(response.data['refs'][0]['pos_output']['finish'], 6)
self.assertEqual(response.data['refs'][1]['type'], ReferenceType.entity.value)
self.assertEqual(response.data['refs'][1]['resolved'], 'синим слонам')
self.assertEqual(response.data['refs'][1]['data']['entity'], 'X1')
self.assertEqual(response.data['refs'][1]['data']['form'], 'plur,datv')
self.assertEqual(response.data['refs'][1]['pos_input']['start'], 12)
self.assertEqual(response.data['refs'][1]['pos_input']['finish'], 27)
self.assertEqual(response.data['refs'][1]['pos_output']['start'], 7)
self.assertEqual(response.data['refs'][1]['pos_output']['finish'], 19)
@decl_endpoint('/api/rsforms/import-trs', method='post')
def test_import_trs(self):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file}
response = self.client.post(self.endpoint, data=data, format='multipart')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertTrue(response.data['title'] != '')
@decl_endpoint('/api/rsforms/{item}/export-trs', method='get')
def test_export_trs(self):
schema = RSForm.create(title='Test')
schema.insert_new('X1')
response = self.executeOK(item=schema.item.pk)
self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename=Schema.trs')
with io.BytesIO(response.content) as stream:
with ZipFile(stream, 'r') as zipped_file:
self.assertIsNone(zipped_file.testzip())
self.assertIn('document.json', zipped_file.namelist())
@decl_endpoint('/api/rsforms/{item}/cst-create', method='post')
def test_create_constituenta(self):
data = {'alias': 'X3', 'cst_type': CstType.BASE}
self.executeForbidden(data, item=self.unowned_id)
self.owned.insert_new('X1')
x2 = self.owned.insert_new('X2')
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
x3 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x3.order, 3)
data = {
'alias': 'X4',
'cst_type': CstType.BASE,
'insert_after': x2.pk,
'term_raw': 'test',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}]
}
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], data['alias'])
x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x4.order, 3)
self.assertEqual(x4.term_raw, data['term_raw'])
self.assertEqual(x4.term_forms, data['term_forms'])
@decl_endpoint('/api/rsforms/{item}/cst-rename', method='patch')
def test_rename_constituenta(self):
x1 = self.owned.insert_new(
alias='X1',
convention='Test',
term_raw='Test1',
term_resolved='Test1',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}]
)
x2_2 = self.unowned.insert_new('X2')
x3 = self.owned.insert_new(
alias='X3',
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
data = {'target': x2_2.pk, 'alias': 'D2', 'cst_type': CstType.TERM}
self.executeForbidden(data, item=self.unowned_id)
self.executeBadData(data, item=self.owned_id)
data = {'target': x1.pk, 'alias': x1.alias, 'cst_type': CstType.TERM}
self.executeBadData(data, item=self.owned_id)
data = {'target': x1.pk, 'alias': x3.alias}
self.executeBadData(data, item=self.owned_id)
d1 = self.owned.insert_new(
alias='D1',
term_raw='@{X1|plur}',
definition_formal='X1'
)
self.assertEqual(x1.order, 1)
self.assertEqual(x1.alias, 'X1')
self.assertEqual(x1.cst_type, CstType.BASE)
data = {'target': x1.pk, 'alias': 'D2', 'cst_type': CstType.TERM}
response = self.executeOK(data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], 'D2')
self.assertEqual(response.data['new_cst']['cst_type'], CstType.TERM)
d1.refresh_from_db()
x1.refresh_from_db()
self.assertEqual(d1.term_resolved, '')
self.assertEqual(d1.term_raw, '@{D2|plur}')
self.assertEqual(x1.order, 1)
self.assertEqual(x1.alias, 'D2')
self.assertEqual(x1.cst_type, CstType.TERM)
@decl_endpoint('/api/rsforms/{item}/cst-substitute', method='patch')
def test_substitute_single(self):
x1 = self.owned.insert_new(
alias='X1',
term_raw='Test1',
term_resolved='Test1',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}]
)
x2 = self.owned.insert_new(
alias='X2',
term_raw='Test2'
)
unowned = self.unowned.insert_new('X2')
data = {'substitutions': [{'original': x1.pk, 'substitution': unowned.pk, 'transfer_term': True}]}
self.executeForbidden(data, item=self.unowned_id)
self.executeBadData(data, item=self.owned_id)
data = {'substitutions': [{'original': unowned.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data, item=self.owned_id)
data = {'substitutions': [{'original': x1.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data, item=self.owned_id)
d1 = self.owned.insert_new(
alias='D1',
term_raw='@{X2|sing,datv}',
definition_formal='X1'
)
data = {'substitutions': [{'original': x1.pk, 'substitution': x2.pk, 'transfer_term': True}]}
response = self.executeOK(data, item=self.owned_id)
d1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(x2.term_raw, 'Test1')
self.assertEqual(d1.term_resolved, 'form1')
self.assertEqual(d1.definition_formal, 'X2')
@decl_endpoint('/api/rsforms/{item}/cst-substitute', method='patch')
def test_substitute_multiple(self):
self.set_params(item=self.owned_id)
x1 = self.owned.insert_new('X1')
x2 = self.owned.insert_new('X2')
d1 = self.owned.insert_new('D1')
d2 = self.owned.insert_new('D2')
d3 = self.owned.insert_new(
alias='D3',
definition_formal=r'X1 \ X2'
)
data = {'substitutions': []}
self.executeBadData(data)
data = {'substitutions': [
{
'original': x1.pk,
'substitution': d1.pk,
'transfer_term': True
},
{
'original': x1.pk,
'substitution': d2.pk,
'transfer_term': True
}
]}
self.executeBadData(data)
data = {'substitutions': [
{
'original': x1.pk,
'substitution': d1.pk,
'transfer_term': True
},
{
'original': x2.pk,
'substitution': d2.pk,
'transfer_term': True
}
]}
response = self.executeOK(data, item=self.owned_id)
d3.refresh_from_db()
self.assertEqual(d3.definition_formal, r'D1 \ D2')
@decl_endpoint('/api/rsforms/{item}/cst-create', method='post')
def test_create_constituenta_data(self):
data = {
'alias': 'X3',
'cst_type': CstType.BASE,
'convention': '1',
'term_raw': '2',
'definition_formal': '3',
'definition_raw': '4'
}
response = self.executeCreated(data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
self.assertEqual(response.data['new_cst']['cst_type'], CstType.BASE)
self.assertEqual(response.data['new_cst']['convention'], '1')
self.assertEqual(response.data['new_cst']['term_raw'], '2')
self.assertEqual(response.data['new_cst']['term_resolved'], '2')
self.assertEqual(response.data['new_cst']['definition_formal'], '3')
self.assertEqual(response.data['new_cst']['definition_raw'], '4')
self.assertEqual(response.data['new_cst']['definition_resolved'], '4')
@decl_endpoint('/api/rsforms/{item}/cst-delete-multiple', method='patch')
def test_delete_constituenta(self):
self.set_params(item=self.owned_id)
data = {'items': [1337]}
self.executeBadData(data)
x1 = self.owned.insert_new('X1')
x2 = self.owned.insert_new('X2')
data = {'items': [x1.pk]}
response = self.executeOK(data)
x2.refresh_from_db()
self.owned.item.refresh_from_db()
self.assertEqual(len(response.data['items']), 1)
self.assertEqual(self.owned.constituents().count(), 1)
self.assertEqual(x2.alias, 'X2')
self.assertEqual(x2.order, 1)
x3 = self.unowned.insert_new('X1')
data = {'items': [x3.pk]}
self.executeBadData(data, item=self.owned_id)
@decl_endpoint('/api/rsforms/{item}/cst-moveto', method='patch')
def test_move_constituenta(self):
self.set_params(item=self.owned_id)
data = {'items': [1337], 'move_to': 1}
self.executeBadData(data)
x1 = self.owned.insert_new('X1')
x2 = self.owned.insert_new('X2')
data = {'items': [x2.pk], 'move_to': 1}
response = self.executeOK(data)
x1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(response.data['id'], self.owned_id)
self.assertEqual(x1.order, 2)
self.assertEqual(x2.order, 1)
x3 = self.unowned.insert_new('X1')
data = {'items': [x3.pk], 'move_to': 1}
self.executeBadData(data)
@decl_endpoint('/api/rsforms/{item}/reset-aliases', method='patch')
def test_reset_aliases(self):
self.set_params(item=self.owned_id)
response = self.executeOK()
self.assertEqual(response.data['id'], self.owned_id)
x2 = self.owned.insert_new('X2')
x1 = self.owned.insert_new('X1')
d11 = self.owned.insert_new('D11')
response = self.executeOK()
x1.refresh_from_db()
x2.refresh_from_db()
d11.refresh_from_db()
self.assertEqual(x2.order, 1)
self.assertEqual(x2.alias, 'X1')
self.assertEqual(x1.order, 2)
self.assertEqual(x1.alias, 'X2')
self.assertEqual(d11.order, 3)
self.assertEqual(d11.alias, 'D1')
self.executeOK()
@decl_endpoint('/api/rsforms/{item}/load-trs', method='patch')
def test_load_trs(self):
self.set_params(item=self.owned_id)
self.owned.item.title = 'Test11'
self.owned.item.save()
x1 = self.owned.insert_new('X1')
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False}
response = self.client.patch(self.endpoint, data=data, format='multipart')
self.owned.item.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.owned.item.title, 'Test11')
self.assertEqual(len(response.data['items']), 25)
self.assertEqual(self.owned.constituents().count(), 25)
self.assertFalse(Constituenta.objects.filter(pk=x1.pk).exists())
@decl_endpoint('/api/rsforms/{item}/cst-produce-structure', method='patch')
def test_produce_structure(self):
self.set_params(item=self.owned_id)
x1 = self.owned.insert_new('X1')
s1 = self.owned.insert_new(
alias='S1',
definition_formal='(X1×X1)'
)
s2 = self.owned.insert_new(
alias='S2',
definition_formal='invalid'
)
s3 = self.owned.insert_new(
alias='S3',
definition_formal='X1×(X1×(X1))×(X1×X1)'
)
a1 = self.owned.insert_new(
alias='A1',
definition_formal='1=1'
)
f1 = self.owned.insert_new(
alias='F10',
definition_formal='[α∈X1, β∈X1] Fi1[{α,β}](S1)'
)
invalid_id = f1.pk + 1337
self.executeBadData({'target': invalid_id})
self.executeBadData({'target': x1.pk})
self.executeBadData({'target': s2.pk})
# Testing simple structure
response = self.executeOK(data={'target': s1.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2)
self.assertEqual(items[0]['order'], s1.order + 1)
self.assertEqual(items[0]['definition_formal'], 'Pr1(S1)')
self.assertEqual(items[1]['order'], s1.order + 2)
self.assertEqual(items[1]['definition_formal'], 'Pr2(S1)')
# Testing complex structure
s3.refresh_from_db()
response = self.executeOK(data={'target': s3.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 8)
self.assertEqual(items[0]['order'], s3.order + 1)
self.assertEqual(items[0]['definition_formal'], 'pr1(S3)')
# Testing function
f1.refresh_from_db()
response = self.executeOK(data={'target': f1.pk})
result = response.data['schema']
items = [item for item in result['items'] if item['id'] in response.data['cst_list']]
self.assertEqual(len(items), 2)
self.assertEqual(items[0]['order'], f1.order + 1)
self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])')

View File

@ -0,0 +1,37 @@
''' Testing views '''
from ..EndpointTester import EndpointTester, decl_endpoint
class TestRSLanguageViews(EndpointTester):
''' Test RS language endpoints. '''
@decl_endpoint('/api/rslang/to-ascii', method='post')
def test_convert_to_ascii(self):
data = {'data': '1=1'}
self.executeBadData(data)
data = {'expression': '1=1'}
response = self.executeOK(data)
self.assertEqual(response.data['result'], r'1 \eq 1')
@decl_endpoint('/api/rslang/to-math', method='post')
def test_convert_to_math(self):
data = {'data': r'1 \eq 1'}
self.executeBadData(data)
data = {'expression': r'1 \eq 1'}
response = self.executeOK(data)
self.assertEqual(response.data['result'], r'1=1')
@decl_endpoint('/api/rslang/parse-expression', method='post')
def test_parse_expression(self):
data = {'data': r'1=1'}
self.executeBadData(data)
data = {'expression': r'1=1'}
response = self.executeOK(data)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], 'math')
self.assertEqual(response.data['astText'], '[=[1][1]]')

View File

@ -0,0 +1,174 @@
''' Testing API: Versions. '''
import io
from sys import version
from typing import cast
from zipfile import ZipFile
from rest_framework import status
from apps.rsform.models import Constituenta, RSForm
from ..EndpointTester import EndpointTester, decl_endpoint
class TestVersionViews(EndpointTester):
''' Testing versioning endpoints. '''
def setUp(self):
super().setUp()
self.owned = RSForm.create(title='Test', alias='T1', owner=self.user).item
self.schema = RSForm(self.owned)
self.unowned = RSForm.create(title='Test2', alias='T2').item
self.x1 = self.schema.insert_new(
alias='X1',
convention='testStart'
)
@decl_endpoint('/api/rsforms/{schema}/versions/create', method='post')
def test_create_version(self):
invalid_data = {'description': 'test'}
invalid_id = 1338
data = {'version': '1.0.0', 'description': 'test'}
self.executeNotFound(data, schema=invalid_id)
self.executeForbidden(data, schema=self.unowned.pk)
self.executeBadData(invalid_data, schema=self.owned.pk)
response = self.executeCreated(data, schema=self.owned.pk)
self.assertTrue('version' in response.data)
self.assertTrue('schema' in response.data)
self.assertTrue(response.data['version'] in [v['id'] for v in response.data['schema']['versions']])
@decl_endpoint('/api/rsforms/{schema}/versions/{version}', method='get')
def test_retrieve_version(self):
version_id = self._create_version({'version': '1.0.0', 'description': 'test'})
invalid_id = version_id + 1337
self.executeNotFound(schema=invalid_id, version=invalid_id)
self.executeNotFound(schema=self.owned.pk, version=invalid_id)
self.executeNotFound(schema=invalid_id, version=version_id)
self.executeNotFound(schema=self.unowned.pk, version=version_id)
self.owned.alias = 'NewName'
self.owned.save()
self.x1.alias = 'X33'
self.x1.save()
response = self.executeOK(schema=self.owned.pk, version=version_id)
self.assertNotEqual(response.data['alias'], self.owned.alias)
self.assertNotEqual(response.data['items'][0]['alias'], self.x1.alias)
self.assertEqual(response.data['version'], version_id)
@decl_endpoint('/api/versions/{version}', method='get')
def test_access_version(self):
data = {'version': '1.0.0', 'description': 'test'}
version_id = self._create_version(data)
invalid_id = version_id + 1337
self.executeNotFound(version=invalid_id)
self.set_params(version=version_id)
self.logout()
response = self.executeOK()
self.assertEqual(response.data['version'], data['version'])
self.assertEqual(response.data['description'], data['description'])
self.assertEqual(response.data['item'], self.owned.pk)
data = {'version': '1.2.0', 'description': 'test1'}
self.method = 'patch'
self.executeForbidden(data)
self.method = 'delete'
self.executeForbidden()
self.client.force_authenticate(user=self.user)
self.method = 'patch'
self.executeOK(data)
response = self.get()
self.assertEqual(response.data['version'], data['version'])
self.assertEqual(response.data['description'], data['description'])
response = self.delete()
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
response = self.get()
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@decl_endpoint('/api/rsforms/{schema}/versions/{version}', method='get')
def test_retrieve_version_details(self):
a1 = Constituenta.objects.create(
schema=self.owned,
alias='A1',
cst_type='axiom',
definition_formal='X1=X1',
order=2
)
version_id = self._create_version({'version': '1.0.0', 'description': 'test'})
a1.definition_formal = 'X1=X2'
a1.save()
response = self.executeOK(schema=self.owned.pk, version=version_id)
loaded_a1 = response.data['items'][1]
self.assertEqual(loaded_a1['definition_formal'], 'X1=X1')
self.assertEqual(loaded_a1['parse']['status'], 'verified')
@decl_endpoint('/api/versions/{version}/export-file', method='get')
def test_export_version(self):
invalid_id = 1338
self.executeNotFound(version=invalid_id)
version_id = self._create_version({'version': '1.0.0', 'description': 'test'})
response = self.executeOK(version=version_id)
self.assertEqual(
response.headers['Content-Disposition'],
f'attachment; filename={self.owned.alias}.trs'
)
with io.BytesIO(response.content) as stream:
with ZipFile(stream, 'r') as zipped_file:
self.assertIsNone(zipped_file.testzip())
self.assertIn('document.json', zipped_file.namelist())
@decl_endpoint('/api/versions/{version}/restore', method='patch')
def test_restore_version(self):
x1 = self.x1
x2 = self.schema.insert_new('X2')
d1 = self.schema.insert_new('D1', term_raw='TestTerm')
data = {'version': '1.0.0', 'description': 'test'}
version_id = self._create_version(data)
invalid_id = version_id + 1337
d1.delete()
x3 = self.schema.insert_new('X3')
x1.order = x3.order
x1.convention = 'Test2'
x1.term_raw = 'Test'
x1.save()
x3.order = 1
x3.save()
self.executeNotFound(version=invalid_id)
response = self.executeOK(version=version_id)
x1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(len(response.data['items']), 3)
self.assertEqual(x1.order, 1)
self.assertEqual(x1.convention, 'testStart')
self.assertEqual(x1.term_raw, '')
self.assertEqual(x2.order, 2)
self.assertEqual(response.data['items'][2]['alias'], 'D1')
self.assertEqual(response.data['items'][2]['term_raw'], 'TestTerm')
def _create_version(self, data) -> int:
response = self.client.post(
f'/api/rsforms/{self.owned.pk}/versions/create',
data=data, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
return response.data['version'] # type: ignore

View File

@ -0,0 +1,115 @@
''' Unit tests: graph. '''
import unittest
from apps.rsform.graph import Graph
class TestGraph(unittest.TestCase):
''' Test class for graph. '''
def test_construction(self):
graph = Graph()
self.assertFalse(graph.contains(1))
graph.add_node(1)
self.assertTrue(graph.contains(1))
graph.add_edge(2, 3)
self.assertTrue(graph.contains(2))
self.assertTrue(graph.contains(3))
self.assertTrue(graph.has_edge(2, 3))
self.assertFalse(graph.has_edge(3, 2))
graph = Graph({1: [3, 4], 2: [1], 3: [], 4: [], 5: []})
self.assertTrue(graph.contains(1))
self.assertTrue(graph.contains(5))
self.assertTrue(graph.has_edge(1, 3))
self.assertTrue(graph.has_edge(2, 1))
def test_expand_outputs(self):
graph = Graph({
1: [2],
2: [3, 5],
3: [],
5: [6],
6: [1],
7: []
})
self.assertEqual(graph.expand_outputs([]), [])
self.assertEqual(graph.expand_outputs([3]), [])
self.assertEqual(graph.expand_outputs([7]), [])
self.assertEqual(graph.expand_outputs([2, 5]), [3, 6, 1])
def test_expand_inputs(self):
graph = Graph({
1: [2],
2: [3, 5],
3: [],
5: [6],
6: [1],
7: []
})
self.assertEqual(graph.expand_inputs([]), [])
self.assertEqual(graph.expand_inputs([1]), [6, 5, 2])
self.assertEqual(graph.expand_inputs([7]), [])
self.assertEqual(graph.expand_inputs([3]), [2, 1, 6, 5])
self.assertEqual(graph.expand_inputs([2, 5]), [1, 6])
def test_transitive_closure(self):
graph = Graph({
1: [2],
2: [3, 5],
3: [],
5: [6],
6: [],
7: [6]
})
self.assertEqual(graph.transitive_closure(), {
1: [2, 3, 5, 6],
2: [3, 5, 6],
3: [],
5: [6],
6: [],
7: [6]
})
def test_topological_order(self):
self.assertEqual(Graph().topological_order(), [])
graph = Graph({
1: [],
2: [1],
3: [],
4: [3],
5: [6],
6: [1, 2]
})
self.assertEqual(graph.topological_order(), [5, 6, 4, 3, 2, 1])
graph = Graph({
1: [1],
2: [4],
3: [2],
4: [],
5: [2],
})
self.assertEqual(graph.topological_order(), [5, 3, 2, 4, 1])
def test_sort_stable(self):
graph = Graph({
1: [2],
2: [3, 5],
3: [],
5: [6],
6: [],
7: [6]
})
self.assertEqual(graph.sort_stable([]), [])
self.assertEqual(graph.sort_stable([1]), [1])
self.assertEqual(graph.sort_stable([1, 2]), [1, 2])
self.assertEqual(graph.sort_stable([7, 2, 1]), [7, 1, 2])
self.assertEqual(graph.sort_stable([2, 1, 7]), [1, 2, 7])
self.assertEqual(graph.sort_stable([1, 2, 7]), [1, 2, 7])
self.assertEqual(graph.sort_stable([2, 1, 3, 6, 7]), [1, 2, 3, 7, 6])
self.assertEqual(graph.sort_stable([2, 1, 6, 7, 3]), [1, 2, 7, 6, 3])

View File

@ -0,0 +1,142 @@
''' Testing imported pyconcept functionality '''
import json
from unittest import TestCase as RegularTest
import pyconcept as pc
class TestIntegrations(RegularTest):
def test_convert_to_ascii(self):
''' Test converting to ASCII syntax '''
self.assertEqual(pc.convert_to_ascii(''), '')
self.assertEqual(pc.convert_to_ascii('\u212c(X1)'), r'B(X1)')
def test_convert_to_math(self):
''' Test converting to MATH syntax '''
self.assertEqual(pc.convert_to_math(''), '')
self.assertEqual(pc.convert_to_math(r'B(X1)'), '\u212c(X1)')
def test_parse_expression(self):
''' Test parsing expression '''
out = json.loads(pc.parse_expression('X1=X2'))
self.assertEqual(out['parseResult'], True)
self.assertEqual(out['syntax'], 'math')
def test_empty_schema(self):
with self.assertRaises(RuntimeError):
pc.check_schema('')
def test_check_schema(self):
schema = self._default_schema()
self.assertTrue(pc.check_schema(schema) != '')
def test_check_expression(self):
schema = self._default_schema()
out1 = json.loads(pc.check_expression(schema, 'X1=X1'))
self.assertTrue(out1['parseResult'])
self.assertEqual(len(out1['args']), 0)
out2 = json.loads(pc.check_expression(schema, 'X1=X2'))
self.assertFalse(out2['parseResult'])
def test_reset_aliases(self):
''' Test reset aliases in schema '''
schema = self._default_schema()
fixedSchema = json.loads(pc.reset_aliases(schema))
self.assertTrue(len(fixedSchema['items']) > 2)
self.assertEqual(fixedSchema['items'][2]['alias'], 'S1')
def _default_schema(self):
return '''{
"type": "rsform",
"title": "default",
"alias": "default",
"comment": "",
"items": [
{
"entityUID": 1023383816,
"type": "constituenta",
"cstType": "basic",
"alias": "X1",
"convention": "",
"term": {
"raw": "",
"resolved": "",
"forms": []
},
"definition": {
"formal": "",
"text": {
"raw": "",
"resolved": ""
}
}
},
{
"entityUID": 1877659352,
"type": "constituenta",
"cstType": "basic",
"alias": "X2",
"convention": "",
"term": {
"raw": "",
"resolved": "",
"forms": []
},
"definition": {
"formal": "",
"text": {
"raw": "",
"resolved": ""
}
}
},
{
"entityUID": 1115937389,
"type": "constituenta",
"cstType": "structure",
"alias": "S2",
"convention": "",
"term": {
"raw": "",
"resolved": "",
"forms": []
},
"definition": {
"formal": "(X1×X1)",
"text": {
"raw": "",
"resolved": ""
}
}
},
{
"entityUID": 94433573,
"type": "constituenta",
"cstType": "structure",
"alias": "S3",
"convention": "",
"term": {
"raw": "",
"resolved": "",
"forms": []
},
"definition": {
"formal": "(X1×X2)",
"text": {
"raw": "",
"resolved": ""
}
}
}
]
}'''

View File

@ -0,0 +1,22 @@
''' Testing serializers '''
from django.test import TestCase
from apps.rsform.serializers import ExpressionSerializer
class TestExpressionSerializer(TestCase):
def setUp(self):
pass
def test_validate(self):
serializer = ExpressionSerializer(data={'expression': 'X1=X1'})
self.assertTrue(serializer.is_valid(raise_exception=False))
self.assertEqual(serializer.validated_data['expression'], 'X1=X1')
def test_missing_data(self):
serializer = ExpressionSerializer(data={})
self.assertFalse(serializer.is_valid(raise_exception=False))
serializer = ExpressionSerializer(data={'schema': 1})
self.assertFalse(serializer.is_valid(raise_exception=False))

View File

@ -0,0 +1,25 @@
''' Unit tests: utils. '''
import re
import unittest
from apps.rsform.utils import apply_pattern, fix_old_references
class TestUtils(unittest.TestCase):
''' Test various utility functions. '''
def test_apply_mapping_patter(self):
mapping = {'X101': 'X20'}
pattern = re.compile(r'(X[0-9]+)')
self.assertEqual(apply_pattern('', mapping, pattern), '')
self.assertEqual(apply_pattern('X20', mapping, pattern), 'X20')
self.assertEqual(apply_pattern('X101', mapping, pattern), 'X20')
self.assertEqual(apply_pattern('asdf X101 asdf', mapping, pattern), 'asdf X20 asdf')
def test_fix_old_references(self):
self.assertEqual(fix_old_references(''), '')
self.assertEqual(fix_old_references('X20'), 'X20')
self.assertEqual(fix_old_references('@{X1|nomn,sing}'), '@{X1|nomn,sing}')
self.assertEqual(fix_old_references('@{X1|sing,ablt} @{X1|sing,ablt}'), '@{X1|sing,ablt} @{X1|sing,ablt}')
self.assertEqual(fix_old_references('@{X1|nomn|sing}'), '@{X1|nomn,sing}')

View File

@ -0,0 +1,8 @@
''' Utilities for testing. '''
from apps.rsform.models import LibraryItem
def response_contains(response, item: LibraryItem) -> bool:
''' Check if response contains specific item. '''
return any(x for x in response.data if x['id'] == item.pk)

View File

@ -0,0 +1,35 @@
''' Routing: RSForms for conceptual schemas. '''
from django.urls import include, path
from rest_framework import routers
from . import views
library_router = routers.SimpleRouter(trailing_slash=False)
library_router.register('library', views.LibraryViewSet, 'Library')
library_router.register('rsforms', views.RSFormViewSet, 'RSForm')
library_router.register('versions', views.VersionViewset, 'Version')
urlpatterns = [
path('library/active', views.LibraryActiveView.as_view()),
path('library/all', views.LibraryAdminView.as_view()),
path('library/templates', views.LibraryTemplatesView.as_view(), name='templates'),
path('constituents/<int:pk>', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs', views.TrsImportView.as_view()),
path('rsforms/create-detailed', views.create_rsform),
path('versions/<int:pk>/export-file', views.export_file),
path('rsforms/<int:pk_item>/versions/create', views.create_version),
path('rsforms/<int:pk_item>/versions/<int:pk_version>', views.retrieve_version),
path('operations/inline-synthesis', views.inline_synthesis),
path('rslang/parse-expression', views.parse_expression),
path('rslang/to-ascii', views.convert_to_ascii),
path('rslang/to-math', views.convert_to_math),
path('cctext/inflect', views.inflect),
path('cctext/generate-lexeme', views.generate_lexeme),
path('cctext/parse', views.parse_text),
path('', include(library_router.urls)),
]

View File

@ -0,0 +1,68 @@
''' Utility functions '''
import json
import re
from io import BytesIO
from zipfile import ZipFile
# Name for JSON inside Exteor files archive
EXTEOR_INNER_FILENAME = 'document.json'
# Old style reference pattern
_REF_OLD_PATTERN = re.compile(r'@{([^0-9\-][^\}\|\{]*?)\|([^\}\|\{]*?)\|([^\}\|\{]*?)}')
def read_zipped_json(data, json_filename: str) -> dict:
''' Read JSON from zipped data '''
with ZipFile(data, 'r') as archive:
json_data = archive.read(json_filename)
result: dict = json.loads(json_data)
return result
def write_zipped_json(json_data: dict, json_filename: str) -> bytes:
''' Write json JSON to bytes buffer '''
content = BytesIO()
data = json.dumps(json_data, indent=4, ensure_ascii=False)
with ZipFile(content, 'w') as archive:
archive.writestr(json_filename, data=data)
return content.getvalue()
def apply_pattern(text: str, mapping: dict[str, str], pattern: re.Pattern[str]) -> str:
''' Apply mapping to matching in regular expression pattern subgroup 1 '''
if text == '' or pattern == '':
return text
pos_input: int = 0
output: str = ''
for segment in re.finditer(pattern, text):
entity = segment.group(1)
if entity in mapping:
output += text[pos_input: segment.start(1)]
output += mapping[entity]
output += text[segment.end(1): segment.end(0)]
pos_input = segment.end(0)
output += text[pos_input: len(text)]
return output
def fix_old_references(text: str) -> str:
''' Fix reference format: @{X1|nomn|sing} -> {X1|nomn,sing} '''
if text == '':
return text
pos_input: int = 0
output: str = ''
for segment in re.finditer(_REF_OLD_PATTERN, text):
output += text[pos_input: segment.start(0)]
output += f'@{{{segment.group(1)}|{segment.group(2)},{segment.group(3)}}}'
pos_input = segment.end(0)
output += text[pos_input: len(text)]
return output
def filename_for_schema(alias: str) -> str:
''' Generate filename for schema from alias. '''
if alias == '' or not alias.isascii():
# Note: non-ascii symbols in Content-Disposition
# are not supported by some browsers
return 'Schema.trs'
return alias + '.trs'

View File

@ -0,0 +1,8 @@
''' REST API: Endpoint processors. '''
from .cctext import generate_lexeme, inflect, parse_text
from .constituents import ConstituentAPIView
from .library import LibraryActiveView, LibraryAdminView, LibraryTemplatesView, LibraryViewSet
from .operations import inline_synthesis
from .rsforms import RSFormViewSet, TrsImportView, create_rsform
from .rslang import convert_to_ascii, convert_to_math, parse_expression
from .versions import VersionViewset, create_version, export_file, retrieve_version

View File

@ -0,0 +1,70 @@
''' Endpoints for cctext. '''
import cctext as ct
from drf_spectacular.utils import extend_schema
from rest_framework import status as c
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import serializers as s
@extend_schema(
summary='generate wordform',
tags=['NaturalLanguage'],
request=s.WordFormSerializer,
responses={200: s.ResultTextResponse},
auth=None
)
@api_view(['POST'])
def inflect(request: Request):
''' Endpoint: Generate wordform with set grammemes. '''
serializer = s.WordFormSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text']
grams = serializer.validated_data['grams']
result = ct.inflect(text, grams)
return Response(
status=c.HTTP_200_OK,
data={'result': result}
)
@extend_schema(
summary='all wordforms for current lexeme',
tags=['NaturalLanguage'],
request=s.TextSerializer,
responses={200: s.MultiFormSerializer},
auth=None
)
@api_view(['POST'])
def generate_lexeme(request: Request):
''' Endpoint: Generate complete set of wordforms for lexeme. '''
serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
nominal = serializer.validated_data['text']
result = ct.generate_lexeme(nominal)
return Response(
status=c.HTTP_200_OK,
data=s.MultiFormSerializer.from_list(result)
)
@extend_schema(
summary='get likely parse grammemes',
tags=['NaturalLanguage'],
request=s.TextSerializer,
responses={200: s.ResultTextResponse},
auth=None
)
@api_view(['POST'])
def parse_text(request: Request):
''' Endpoint: Get likely vocabulary parse. '''
serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text']
result = ct.parse(text)
return Response(
status=c.HTTP_200_OK,
data={'result': result}
)

View File

@ -0,0 +1,15 @@
''' Endpoints for Constituenta. '''
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from .. import models as m
from .. import permissions
from .. import serializers as s
@extend_schema(tags=['Constituenta'])
@extend_schema_view()
class ConstituentAPIView(generics.RetrieveUpdateAPIView, permissions.EditorMixin):
''' Endpoint: Get / Update Constituenta. '''
queryset = m.Constituenta.objects.all()
serializer_class = s.CstSerializer

View File

@ -0,0 +1,313 @@
''' Endpoints for library. '''
from copy import deepcopy
from typing import cast
from django.db import transaction
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import filters, generics
from rest_framework import status as c
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from .. import models as m
from .. import permissions
from .. import serializers as s
@extend_schema(tags=['Library'])
@extend_schema_view()
class LibraryViewSet(viewsets.ModelViewSet):
''' Endpoint: Library operations. '''
queryset = m.LibraryItem.objects.all()
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_fields = ['item_type', 'owner']
ordering_fields = ('item_type', 'owner', 'alias', 'title', 'time_update')
ordering = '-time_update'
def get_serializer_class(self):
if self.action == 'create':
return s.LibraryItemBaseSerializer
return s.LibraryItemSerializer
def perform_create(self, serializer):
if not self.request.user.is_anonymous and 'owner' not in self.request.POST:
return serializer.save(owner=self.request.user)
else:
return serializer.save()
def get_permissions(self):
if self.action in ['update', 'partial_update']:
permission_list = [permissions.ItemEditor]
elif self.action in [
'destroy', 'set_owner', 'set_access_policy', 'set_location',
'editors_add', 'editors_remove', 'editors_set'
]:
permission_list = [permissions.ItemOwner]
elif self.action in ['create', 'clone', 'subscribe', 'unsubscribe']:
permission_list = [permissions.GlobalUser]
else:
permission_list = [permissions.ItemAnyone]
return [permission() for permission in permission_list]
def _get_item(self) -> m.LibraryItem:
return cast(m.LibraryItem, self.get_object())
@extend_schema(
summary='clone item including contents',
tags=['Library'],
request=s.LibraryItemCloneSerializer,
responses={
c.HTTP_201_CREATED: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['post'], url_path='clone')
def clone(self, request: Request, pk):
''' Endpoint: Create deep copy of library item. '''
serializer = s.LibraryItemCloneSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = self._get_item()
clone = deepcopy(item)
clone.pk = None
clone.owner = self.request.user
clone.title = serializer.validated_data['title']
clone.alias = serializer.validated_data.get('alias', '')
clone.comment = serializer.validated_data.get('comment', '')
clone.visible = serializer.validated_data.get('visible', True)
clone.read_only = False
clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC)
clone.location = serializer.validated_data.get('location', m.LocationHead.USER)
clone.save()
if clone.item_type == m.LibraryItemType.RSFORM:
need_filter = 'items' in request.data
for cst in m.RSForm(item).constituents():
if not need_filter or cst.pk in request.data['items']:
cst.pk = None
cst.schema = clone
cst.save()
return Response(
status=c.HTTP_201_CREATED,
data=s.RSFormParseSerializer(clone).data
)
return Response(status=c.HTTP_400_BAD_REQUEST)
@extend_schema(
summary='subscribe to item',
tags=['Library'],
request=None,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'])
def subscribe(self, request: Request, pk):
''' Endpoint: Subscribe current user to item. '''
item = self._get_item()
m.Subscription.subscribe(user=cast(m.User, self.request.user), item=item)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='unsubscribe from item',
tags=['Library'],
request=None,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
},
)
@action(detail=True, methods=['delete'])
def unsubscribe(self, request: Request, pk):
''' Endpoint: Unsubscribe current user from item. '''
item = self._get_item()
m.Subscription.unsubscribe(user=cast(m.User, self.request.user), item=item)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set owner for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-owner')
def set_owner(self, request: Request, pk):
''' Endpoint: Set item owner. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_owner = serializer.validated_data['user']
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set AccessPolicy for item',
tags=['Library'],
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=serializer.validated_data['access_policy'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set location for item',
tags=['Library'],
request=s.LocationSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-location')
def set_location(self, request: Request, pk):
''' Endpoint: Set item location. '''
item = self._get_item()
serializer = s.LocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
location: str = serializer.validated_data['location']
if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff:
return Response(status=c.HTTP_403_FORBIDDEN)
m.LibraryItem.objects.filter(pk=item.pk).update(location=location)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='add editor for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='editors-add')
def editors_add(self, request: Request, pk):
''' Endpoint: Add editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_editor = serializer.validated_data['user']
m.Editor.add(item=item, user=new_editor)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='remove editor for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='editors-remove')
def editors_remove(self, request: Request, pk):
''' Endpoint: Remove editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editor = serializer.validated_data['user']
m.Editor.remove(item=item, user=editor)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set list of editors for item',
tags=['Library'],
request=s.UsersListSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='editors-set')
def editors_set(self, request: Request, pk):
''' Endpoint: Set list of editors for item. '''
item = self._get_item()
serializer = s.UsersListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editors = serializer.validated_data['users']
m.Editor.set(item=item, users=editors)
return Response(status=c.HTTP_200_OK)
@extend_schema(tags=['Library'])
@extend_schema_view()
class LibraryActiveView(generics.ListAPIView):
''' Endpoint: Get list of library items available for active user. '''
permission_classes = (permissions.Anyone,)
serializer_class = s.LibraryItemSerializer
def get_queryset(self):
if self.request.user.is_anonymous:
return m.LibraryItem.objects.filter(
Q(access_policy=m.AccessPolicy.PUBLIC),
).filter(
Q(location__startswith=m.LocationHead.COMMON) |
Q(location__startswith=m.LocationHead.LIBRARY)
).order_by('-time_update')
else:
user = cast(m.User, self.request.user)
# pylint: disable=unsupported-binary-operation
return m.LibraryItem.objects.filter(
(
Q(access_policy=m.AccessPolicy.PUBLIC) &
(
Q(location__startswith=m.LocationHead.COMMON) |
Q(location__startswith=m.LocationHead.LIBRARY)
)
) |
Q(owner=user) |
Q(editor__editor=user) |
Q(subscription__user=user)
).distinct().order_by('-time_update')
@extend_schema(tags=['Library'])
@extend_schema_view()
class LibraryAdminView(generics.ListAPIView):
''' Endpoint: Get list of all library items. Admin only '''
permission_classes = (permissions.GlobalAdmin,)
serializer_class = s.LibraryItemSerializer
def get_queryset(self):
return m.LibraryItem.objects.all().order_by('-time_update')
@extend_schema(tags=['Library'])
@extend_schema_view()
class LibraryTemplatesView(generics.ListAPIView):
''' Endpoint: Get list of templates. '''
permission_classes = (permissions.Anyone,)
serializer_class = s.LibraryItemSerializer
def get_queryset(self):
template_ids = m.LibraryTemplate.objects.values_list('lib_source', flat=True)
return m.LibraryItem.objects.filter(pk__in=template_ids)

View File

@ -0,0 +1,50 @@
''' Endpoints for RSForm. '''
from typing import cast
from django.db import transaction
from drf_spectacular.utils import extend_schema
from rest_framework import status as c
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import models as m
from .. import serializers as s
@extend_schema(
summary='Inline synthesis: merge one schema into another',
tags=['Operations'],
request=s.InlineSynthesisSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
)
@transaction.atomic
@api_view(['PATCH'])
def inline_synthesis(request: Request):
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
schema = m.RSForm(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
new_items = schema.insert_copy(items)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst == original)
original = new_items[index]
else:
index = next(i for (i, cst) in enumerate(items) if cst == replacement)
replacement = new_items[index]
schema.substitute(original, replacement, substitution['transfer_term'])
schema.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)

View File

@ -0,0 +1,492 @@
''' Endpoints for RSForm. '''
import json
from typing import Union, cast
import pyconcept
from django.db import transaction
from django.http import HttpResponse
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from rest_framework import status as c
from rest_framework import views, viewsets
from rest_framework.decorators import action, api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import messages as msg
from .. import models as m
from .. import permissions
from .. import serializers as s
from .. import utils
@extend_schema(tags=['RSForm'])
@extend_schema_view()
class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView):
''' Endpoint: RSForm operations. '''
queryset = m.LibraryItem.objects.filter(item_type=m.LibraryItemType.RSFORM)
serializer_class = s.LibraryItemSerializer
def _get_schema(self) -> m.RSForm:
return m.RSForm(cast(m.LibraryItem, self.get_object()))
def get_permissions(self):
''' Determine permission class. '''
if self.action in [
'load_trs', 'cst_create', 'cst_delete_multiple',
'reset_aliases', 'cst_rename', 'cst_substitute'
]:
permission_list = [permissions.ItemEditor]
elif self.action in ['contents', 'details', 'export_trs', 'resolve', 'check']:
permission_list = [permissions.ItemAnyone]
else:
permission_list = [permissions.Anyone]
return [permission() for permission in permission_list]
@extend_schema(
summary='create constituenta',
tags=['Constituenta'],
request=s.CstCreateSerializer,
responses={
c.HTTP_201_CREATED: s.NewCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='cst-create')
def cst_create(self, request: Request, pk):
''' Create new constituenta. '''
schema = self._get_schema()
serializer = s.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
new_cst = schema.create_cst(
data=data,
insert_after=data['insert_after'] if 'insert_after' in data else None
)
schema.item.refresh_from_db()
response = Response(
status=c.HTTP_201_CREATED,
data={
'new_cst': s.CstSerializer(new_cst).data,
'schema': s.RSFormParseSerializer(schema.item).data
}
)
response['Location'] = new_cst.get_absolute_url()
return response
@extend_schema(
summary='produce the structure of a given constituenta',
tags=['RSForm'],
request=s.CstTargetSerializer,
responses={
c.HTTP_200_OK: s.NewMultiCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-produce-structure')
def produce_structure(self, request: Request, pk):
''' Produce a term for every element of the target constituenta typification. '''
schema = self._get_schema()
serializer = s.CstTargetSerializer(data=request.data, context={'schema': schema.item})
serializer.is_valid(raise_exception=True)
cst = cast(m.Constituenta, serializer.validated_data['target'])
schema_details = s.RSFormParseSerializer(schema.item).data['items']
cst_parse = next(item for item in schema_details if item['id'] == cst.id)['parse']
if not cst_parse['typification']:
return Response(
status=c.HTTP_400_BAD_REQUEST,
data={f'{cst.id}': msg.constituentaNoStructure()}
)
result = schema.produce_structure(cst, cst_parse)
return Response(
status=c.HTTP_200_OK,
data={
'cst_list': result,
'schema': s.RSFormParseSerializer(schema.item).data
}
)
@extend_schema(
summary='rename constituenta',
tags=['Constituenta'],
request=s.CstRenameSerializer,
responses={
c.HTTP_200_OK: s.NewCstResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['patch'], url_path='cst-rename')
def cst_rename(self, request: Request, pk):
''' Rename constituenta possibly changing type. '''
schema = self._get_schema()
serializer = s.CstRenameSerializer(data=request.data, context={'schema': schema.item})
serializer.is_valid(raise_exception=True)
cst = cast(m.Constituenta, serializer.validated_data['target'])
old_alias = cst.alias
cst.alias = serializer.validated_data['alias']
cst.cst_type = serializer.validated_data['cst_type']
cst.save()
mapping = {old_alias: cst.alias}
schema.apply_mapping(mapping, change_aliases=False)
schema.item.refresh_from_db()
cst.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data={
'new_cst': s.CstSerializer(cst).data,
'schema': s.RSFormParseSerializer(schema.item).data
}
)
@extend_schema(
summary='substitute constituenta',
tags=['RSForm'],
request=s.CstSubstituteSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@transaction.atomic
@action(detail=True, methods=['patch'], url_path='cst-substitute')
def cst_substitute(self, request: Request, pk):
''' Substitute occurrences of constituenta with another one. '''
schema = self._get_schema()
serializer = s.CstSubstituteSerializer(
data=request.data,
context={'schema': schema.item}
)
serializer.is_valid(raise_exception=True)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
schema.substitute(original, replacement, substitution['transfer_term'])
schema.item.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)
@extend_schema(
summary='delete constituents',
tags=['RSForm'],
request=s.CstListSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-delete-multiple')
def cst_delete_multiple(self, request: Request, pk):
''' Endpoint: Delete multiple constituents. '''
schema = self._get_schema()
serializer = s.CstListSerializer(
data=request.data,
context={'schema': schema.item}
)
serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['items'])
schema.item.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)
@extend_schema(
summary='move constituenta',
tags=['RSForm'],
request=s.CstMoveSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request: Request, pk):
''' Endpoint: Move multiple constituents. '''
schema = self._get_schema()
serializer = s.CstMoveSerializer(
data=request.data,
context={'schema': schema.item}
)
serializer.is_valid(raise_exception=True)
schema.move_cst(
listCst=serializer.validated_data['items'],
target=serializer.validated_data['move_to']
)
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)
@extend_schema(
summary='reset aliases, update expressions and references',
tags=['RSForm'],
request=None,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request: Request, pk):
''' Endpoint: Recreate all aliases based on order. '''
schema = self._get_schema()
schema.reset_aliases()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)
@extend_schema(
summary='restore order based on types and term graph',
tags=['RSForm'],
request=None,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='restore-order')
def restore_order(self, request: Request, pk):
''' Endpoint: Restore order based on types and term graph. '''
schema = self._get_schema()
schema.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(schema.item).data
)
@extend_schema(
summary='load data from TRS file',
tags=['RSForm'],
request=s.RSFormUploadSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request: Request, pk):
''' Endpoint: Load data from file and replace current schema. '''
input_serializer = s.RSFormUploadSerializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
schema = self._get_schema()
load_metadata = input_serializer.validated_data['load_metadata']
data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME)
data['id'] = schema.item.pk
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': load_metadata}
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(result.item).data
)
@extend_schema(
summary='get all constituents data from DB',
tags=['RSForm'],
request=None,
responses={
c.HTTP_200_OK: s.RSFormSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'])
def contents(self, request: Request, pk):
''' Endpoint: View schema db contents (including constituents). '''
schema = s.RSFormSerializer(self.get_object())
return Response(
status=c.HTTP_200_OK,
data=schema.data
)
@extend_schema(
summary='get all constituents data and parses',
tags=['RSForm'],
request=None,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'])
def details(self, request: Request, pk):
''' Endpoint: Detailed schema view including statuses and parse. '''
serializer = s.RSFormParseSerializer(cast(m.LibraryItem, self.get_object()))
return Response(
status=c.HTTP_200_OK,
data=serializer.data
)
@extend_schema(
summary='check RSLang expression',
tags=['RSForm', 'FormalLanguage'],
request=s.ExpressionSerializer,
responses={
c.HTTP_200_OK: s.ExpressionParseSerializer,
c.HTTP_404_NOT_FOUND: None
},
)
@action(detail=True, methods=['post'])
def check(self, request: Request, pk):
''' Endpoint: Check RSLang expression against schema context. '''
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(
status=c.HTTP_200_OK,
data=json.loads(result)
)
@extend_schema(
summary='resolve text with references',
tags=['RSForm', 'NaturalLanguage'],
request=s.TextSerializer,
responses={
c.HTTP_200_OK: s.ResolverSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'])
def resolve(self, request: Request, pk):
''' Endpoint: Resolve references in text against schema terms context. '''
serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text']
resolver = self._get_schema().resolver()
resolver.resolve(text)
return Response(
status=c.HTTP_200_OK,
data=s.ResolverSerializer(resolver).data
)
@extend_schema(
summary='export as TRS file',
tags=['RSForm'],
request=None,
responses={
(c.HTTP_200_OK, 'application/zip'): bytes,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request: Request, pk):
''' Endpoint: Download Exteor compatible file. '''
data = s.RSFormTRSSerializer(self._get_schema()).data
file = utils.write_zipped_json(data, utils.EXTEOR_INNER_FILENAME)
filename = utils.filename_for_schema(self._get_schema().item.alias)
response = HttpResponse(file, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={filename}'
return response
class TrsImportView(views.APIView):
''' Endpoint: Upload RS form in Exteor format. '''
serializer_class = s.FileSerializer
permission_classes = [permissions.GlobalUser]
@extend_schema(
summary='import TRS file into RSForm',
tags=['RSForm'],
request=s.FileSerializer,
responses={
c.HTTP_201_CREATED: s.LibraryItemSerializer,
c.HTTP_403_FORBIDDEN: None
}
)
def post(self, request: Request):
data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME)
owner = cast(m.User, self.request.user)
_prepare_rsform_data(data, request, owner)
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': True}
)
serializer.is_valid(raise_exception=True)
schema = serializer.save()
result = s.LibraryItemSerializer(schema.item)
return Response(
status=c.HTTP_201_CREATED,
data=result.data
)
@extend_schema(
summary='create new RSForm empty or from file',
tags=['RSForm'],
request=s.LibraryItemSerializer,
responses={
c.HTTP_201_CREATED: s.LibraryItemSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None
}
)
@api_view(['POST'])
def create_rsform(request: Request):
''' Endpoint: Create RSForm from user input and/or trs file. '''
owner = cast(m.User, request.user) if not request.user.is_anonymous else None
if 'file' not in request.FILES:
return Response(
status=c.HTTP_400_BAD_REQUEST,
data={'file': msg.missingFile()}
)
else:
data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME)
_prepare_rsform_data(data, request, owner)
serializer_rsform = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer_rsform.is_valid(raise_exception=True)
schema = serializer_rsform.save()
result = s.LibraryItemSerializer(schema.item)
return Response(
status=c.HTTP_201_CREATED,
data=result.data
)
def _prepare_rsform_data(data: dict, request: Request, owner: Union[m.User, None]):
data['owner'] = owner
if 'title' in request.data and request.data['title'] != '':
data['title'] = request.data['title']
if data['title'] == '':
data['title'] = 'Без названия ' + request.FILES['file'].fileName
if 'alias' in request.data and request.data['alias'] != '':
data['alias'] = request.data['alias']
if 'comment' in request.data and request.data['comment'] != '':
data['comment'] = request.data['comment']
visible = True
if 'visible' in request.data:
visible = request.data['visible'] == 'true'
data['visible'] = visible
read_only = False
if 'read_only' in request.data:
read_only = request.data['read_only'] == 'true'
data['read_only'] = read_only
data['access_policy'] = request.data.get('access_policy', m.AccessPolicy.PUBLIC)
data['location'] = request.data.get('location', m.LocationHead.USER)

View File

@ -0,0 +1,71 @@
''' Endpoints pyconcept formal language parsing. '''
import json
import pyconcept
from drf_spectacular.utils import extend_schema
from rest_framework import status as c
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import serializers as s
@extend_schema(
summary='RS expression into Syntax Tree',
tags=['FormalLanguage'],
request=s.ExpressionSerializer,
responses={c.HTTP_200_OK: s.ExpressionParseSerializer},
auth=None
)
@api_view(['POST'])
def parse_expression(request: Request):
''' Endpoint: Parse RS expression. '''
serializer = s.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression']
result = pyconcept.parse_expression(expression)
return Response(
status=c.HTTP_200_OK,
data=json.loads(result)
)
@extend_schema(
summary='Unicode syntax to ASCII TeX',
tags=['FormalLanguage'],
request=s.ExpressionSerializer,
responses={c.HTTP_200_OK: s.ResultTextResponse},
auth=None
)
@api_view(['POST'])
def convert_to_ascii(request: Request):
''' Endpoint: Convert expression to ASCII syntax. '''
serializer = s.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression']
result = pyconcept.convert_to_ascii(expression)
return Response(
status=c.HTTP_200_OK,
data={'result': result}
)
@extend_schema(
summary='ASCII TeX syntax to Unicode symbols',
tags=['FormalLanguage'],
request=s.ExpressionSerializer,
responses={200: s.ResultTextResponse},
auth=None
)
@api_view(['POST'])
def convert_to_math(request: Request):
''' Endpoint: Convert expression to MATH syntax. '''
serializer = s.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression']
result = pyconcept.convert_to_math(expression)
return Response(
status=c.HTTP_200_OK,
data={'result': result}
)

View File

@ -0,0 +1,141 @@
''' Endpoints for versions. '''
from typing import cast
from django.http import HttpResponse
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from rest_framework import status as c
from rest_framework import viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.request import Request
from rest_framework.response import Response
from .. import models as m
from .. import permissions
from .. import serializers as s
from .. import utils
@extend_schema(tags=['Version'])
@extend_schema_view()
class VersionViewset(
viewsets.GenericViewSet,
generics.RetrieveUpdateDestroyAPIView,
permissions.EditorMixin
):
''' Endpoint: Get / Update Constituenta. '''
queryset = m.Version.objects.all()
serializer_class = s.VersionSerializer
@extend_schema(
summary='restore version data into current item',
request=None,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='restore')
def restore(self, request: Request, pk):
''' Restore version data into current item. '''
version = cast(m.Version, self.get_object())
item = cast(m.LibraryItem, version.item)
s.RSFormSerializer(item).restore_from_version(version.data)
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(item).data
)
@extend_schema(
summary='save version for RSForm copying current content',
tags=['Version'],
request=s.VersionCreateSerializer,
responses={
c.HTTP_201_CREATED: s.NewVersionResponse,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@api_view(['POST'])
@permission_classes([permissions.GlobalUser])
def create_version(request: Request, pk_item: int):
''' Endpoint: Create new version for RSForm copying current content. '''
try:
item = m.LibraryItem.objects.get(pk=pk_item)
except m.LibraryItem.DoesNotExist:
return Response(status=c.HTTP_404_NOT_FOUND)
creator = request.user
if not creator.is_staff and creator != item.owner:
return Response(status=c.HTTP_403_FORBIDDEN)
version_input = s.VersionCreateSerializer(data=request.data)
version_input.is_valid(raise_exception=True)
data = s.RSFormSerializer(item).to_versioned_data()
result = m.RSForm(item).create_version(
version=version_input.validated_data['version'],
description=version_input.validated_data['description'],
data=data
)
return Response(
status=c.HTTP_201_CREATED,
data={
'version': result.pk,
'schema': s.RSFormParseSerializer(item).data
}
)
@extend_schema(
summary='retrieve versioned data for RSForm',
tags=['Version'],
request=None,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_404_NOT_FOUND: None
}
)
@api_view(['GET'])
def retrieve_version(request: Request, pk_item: int, pk_version: int):
''' Endpoint: Retrieve version for RSForm. '''
try:
item = m.LibraryItem.objects.get(pk=pk_item)
except m.LibraryItem.DoesNotExist:
return Response(status=c.HTTP_404_NOT_FOUND)
try:
version = m.Version.objects.get(pk=pk_version)
except m.Version.DoesNotExist:
return Response(status=c.HTTP_404_NOT_FOUND)
if version.item != item:
return Response(status=c.HTTP_404_NOT_FOUND)
data = s.RSFormParseSerializer(item).from_versioned_data(version.pk, version.data)
return Response(
status=c.HTTP_200_OK,
data=data
)
@extend_schema(
summary='export versioned data as file',
tags=['Version'],
request=None,
responses={
(c.HTTP_200_OK, 'application/zip'): bytes,
c.HTTP_404_NOT_FOUND: None
}
)
@api_view(['GET'])
def export_file(request: Request, pk: int):
''' Endpoint: Download Exteor compatible file for versioned data. '''
try:
version = m.Version.objects.get(pk=pk)
except m.Version.DoesNotExist:
return Response(status=c.HTTP_404_NOT_FOUND)
data = s.RSFormTRSSerializer(m.RSForm(version.item)).from_versioned_data(version.data)
file = utils.write_zipped_json(data, utils.EXTEOR_INNER_FILENAME)
filename = utils.filename_for_schema(data['alias'])
response = HttpResponse(file, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={filename}'
return response

View File

View File

@ -0,0 +1 @@
''' Admin: User profile and Authorization. '''

View File

@ -0,0 +1,11 @@
''' Application: User profile and Authorization. '''
from django.apps import AppConfig
class UsersConfig(AppConfig):
''' Application config. '''
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users'
def ready(self):
import apps.users.signals # pylint: disable=unused-import,import-outside-toplevel

View File

@ -0,0 +1,14 @@
''' Utility: Text messages. '''
# pylint: skip-file
def passwordAuthFailed():
return 'Неизвестное сочетание имени пользователя (email) и пароля'
def passwordsNotMatch():
return 'Введенные пароли не совпадают'
def emailAlreadyTaken():
return 'Пользователь с данным email уже существует'

View File

@ -0,0 +1,5 @@
''' Models: User profile and Authorization. '''
# Note: using User import to isolate original
# pylint: disable=unused-import,ungrouped-imports
from django.contrib.auth.models import User

View File

@ -0,0 +1,169 @@
''' Serializers: User profile and Authorization. '''
from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from apps.rsform.models import Editor, Subscription
from . import messages as msg
from . import models
class NonFieldErrorSerializer(serializers.Serializer):
''' Serializer: list of non-field errors. '''
non_field_errors = serializers.ListField(
child=serializers.CharField()
)
class LoginSerializer(serializers.Serializer):
''' Serializer: User authentication by login/password. '''
username = serializers.CharField(
label='Имя пользователя',
write_only=True
)
password = serializers.CharField(
label='Пароль',
style={'input_type': 'password'},
trim_whitespace=False,
write_only=True
)
def validate(self, attrs):
username = attrs['username']
if '@' in username:
user = models.User.objects.filter(email=username)
if not user.exists() or user.count() > 1:
raise serializers.ValidationError(
msg.passwordAuthFailed(),
code='authorization'
)
username = user.first().username
password = attrs['password']
authenticated = authenticate(
request=self.context.get('request'),
username=username,
password=password
)
if not authenticated:
raise serializers.ValidationError(
msg.passwordAuthFailed(),
code='authorization'
)
attrs['user'] = authenticated
return attrs
class AuthSerializer(serializers.Serializer):
''' Serializer: Authorization data. '''
id = serializers.IntegerField()
username = serializers.CharField()
is_staff = serializers.BooleanField()
subscriptions = serializers.ListField(
child=serializers.IntegerField()
)
def to_representation(self, instance: models.User) -> dict:
if instance.is_anonymous:
return {
'id': None,
'username': '',
'is_staff': False,
'subscriptions': [],
'editor': []
}
else:
return {
'id': instance.pk,
'username': instance.username,
'is_staff': instance.is_staff,
'subscriptions': [sub.item.pk for sub in Subscription.objects.filter(user=instance)],
'editor': [edit.item.pk for edit in Editor.objects.filter(editor=instance)]
}
class UserInfoSerializer(serializers.ModelSerializer):
''' Serializer: User data. '''
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'first_name',
'last_name',
]
class UserSerializer(serializers.ModelSerializer):
''' Serializer: User data. '''
id = serializers.IntegerField(read_only=True)
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
]
def validate(self, attrs):
attrs = super().validate(attrs)
if 'email' in attrs:
maybe_user = models.User.objects.filter(email=attrs['email'])
if maybe_user.exists():
if maybe_user.count() > 1 or maybe_user.first().pk != self.context['request'].user.pk:
raise serializers.ValidationError({
'email': msg.emailAlreadyTaken()
})
return attrs
class ChangePasswordSerializer(serializers.Serializer):
''' Serializer: Change password. '''
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class SignupSerializer(serializers.ModelSerializer):
''' Serializer: Create user profile. '''
id = serializers.IntegerField(read_only=True)
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
password2 = serializers.CharField(write_only=True, required=True)
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
'password',
'password2'
]
def validate(self, attrs):
if attrs['password'] != attrs['password2']:
raise serializers.ValidationError({
'password': msg.passwordsNotMatch()
})
if models.User.objects.filter(email=attrs['email']).exists():
raise serializers.ValidationError({
'email': msg.emailAlreadyTaken()
})
return attrs
def create(self, validated_data):
user = models.User.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
password=validated_data['password']
)
user.first_name = validated_data['first_name']
user.last_name = validated_data['last_name']
user.save()
return user

Some files were not shown because too many files have changed in this diff Show More