Initial commit
This commit is contained in:
commit
2759f10d09
69
.dockerignore
Normal file
69
.dockerignore
Normal 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
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
43
.github/workflows/backend.yml
vendored
Normal 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
43
.github/workflows/frontend.yml
vendored
Normal 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
67
.gitignore
vendored
Normal 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
87
.vscode/launch.json
vendored
Normal 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
191
.vscode/settings.json
vendored
Normal 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
128
CODE_OF_CONDUCT.md
Normal 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
21
LICENSE
Normal 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
167
README.md
Normal 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 />
|
||||
|
||||
[](https://github.com/IRBorisov/ConceptPortal/actions/workflows/backend.yml)
|
||||
[](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
11
SECURITY.md
Normal 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
64
TODO.txt
Normal 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
51
docker-compose-dev.yml
Normal 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
|
69
docker-compose-prod-local.yml
Normal file
69
docker-compose-prod-local.yml
Normal 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
110
docker-compose-prod.yml
Normal 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
5
nginx/Dockerfile
Normal 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
6
nginx/Dockerfile.local
Normal 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
2
nginx/cert/README.txt
Normal 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
78
nginx/production.conf
Normal 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;
|
||||
}
|
||||
}
|
41
nginx/production.local.conf
Normal file
41
nginx/production.local.conf
Normal 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
39
nginx/starter.conf
Normal 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
5
postgresql/.env.dev
Normal 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
2
postgresql/.env.prod
Normal file
|
@ -0,0 +1,2 @@
|
|||
POSTGRES_USER=portal-admin
|
||||
POSTGRES_DB=portal-db
|
5
postgresql/.env.prod.local
Normal file
5
postgresql/.env.prod.local
Normal 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
|
36
rsconcept/backend/.dockerignore
Normal file
36
rsconcept/backend/.dockerignore
Normal 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
|
37
rsconcept/backend/.env.dev
Normal file
37
rsconcept/backend/.env.dev
Normal 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
|
37
rsconcept/backend/.env.prod
Normal file
37
rsconcept/backend/.env.prod
Normal 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
|
37
rsconcept/backend/.env.prod.local
Normal file
37
rsconcept/backend/.env.prod.local
Normal 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
635
rsconcept/backend/.pylintrc
Normal 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
|
84
rsconcept/backend/Dockerfile
Normal file
84
rsconcept/backend/Dockerfile
Normal 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"]
|
0
rsconcept/backend/apps/__init__.py
Normal file
0
rsconcept/backend/apps/__init__.py
Normal file
0
rsconcept/backend/apps/rsform/__init__.py
Normal file
0
rsconcept/backend/apps/rsform/__init__.py
Normal file
69
rsconcept/backend/apps/rsform/admin.py
Normal file
69
rsconcept/backend/apps/rsform/admin.py
Normal 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)
|
8
rsconcept/backend/apps/rsform/apps.py
Normal file
8
rsconcept/backend/apps/rsform/apps.py
Normal 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'
|
142
rsconcept/backend/apps/rsform/graph.py
Normal file
142
rsconcept/backend/apps/rsform/graph.py
Normal 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
|
66
rsconcept/backend/apps/rsform/messages.py
Normal file
66
rsconcept/backend/apps/rsform/messages.py
Normal 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 'Отсутствует прикрепленный файл'
|
72
rsconcept/backend/apps/rsform/migrations/0001_initial.py
Normal file
72
rsconcept/backend/apps/rsform/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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': 'Шаблоны',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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='Текстовое определение'),
|
||||
),
|
||||
]
|
30
rsconcept/backend/apps/rsform/migrations/0004_version.py
Normal file
30
rsconcept/backend/apps/rsform/migrations/0004_version.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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': 'Версии'},
|
||||
),
|
||||
]
|
30
rsconcept/backend/apps/rsform/migrations/0006_editor.py
Normal file
30
rsconcept/backend/apps/rsform/migrations/0006_editor.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
137
rsconcept/backend/apps/rsform/models/Constituenta.py
Normal file
137
rsconcept/backend/apps/rsform/models/Constituenta.py
Normal 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
|
71
rsconcept/backend/apps/rsform/models/Editor.py
Normal file
71
rsconcept/backend/apps/rsform/models/Editor.py
Normal 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)
|
133
rsconcept/backend/apps/rsform/models/LibraryItem.py
Normal file
133
rsconcept/backend/apps/rsform/models/LibraryItem.py
Normal 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)
|
17
rsconcept/backend/apps/rsform/models/LibraryTemplate.py
Normal file
17
rsconcept/backend/apps/rsform/models/LibraryTemplate.py
Normal 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 = 'Шаблоны'
|
49
rsconcept/backend/apps/rsform/models/Subscription.py
Normal file
49
rsconcept/backend/apps/rsform/models/Subscription.py
Normal 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
|
44
rsconcept/backend/apps/rsform/models/Version.py
Normal file
44
rsconcept/backend/apps/rsform/models/Version.py
Normal 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}'
|
16
rsconcept/backend/apps/rsform/models/__init__.py
Normal file
16
rsconcept/backend/apps/rsform/models/__init__.py
Normal 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
|
616
rsconcept/backend/apps/rsform/models/api_RSForm.py
Normal file
616
rsconcept/backend/apps/rsform/models/api_RSForm.py
Normal 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
|
183
rsconcept/backend/apps/rsform/models/api_RSLanguage.py
Normal file
183
rsconcept/backend/apps/rsform/models/api_RSLanguage.py
Normal 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]
|
93
rsconcept/backend/apps/rsform/permissions.py
Normal file
93
rsconcept/backend/apps/rsform/permissions.py
Normal 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
|
40
rsconcept/backend/apps/rsform/serializers/__init__.py
Normal file
40
rsconcept/backend/apps/rsform/serializers/__init__.py
Normal 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
|
||||
)
|
192
rsconcept/backend/apps/rsform/serializers/basics.py
Normal file
192
rsconcept/backend/apps/rsform/serializers/basics.py
Normal 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
|
||||
}
|
446
rsconcept/backend/apps/rsform/serializers/data_access.py
Normal file
446
rsconcept/backend/apps/rsform/serializers/data_access.py
Normal 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
|
221
rsconcept/backend/apps/rsform/serializers/io_files.py
Normal file
221
rsconcept/backend/apps/rsform/serializers/io_files.py
Normal 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 = []
|
80
rsconcept/backend/apps/rsform/serializers/io_pyconcept.py
Normal file
80
rsconcept/backend/apps/rsform/serializers/io_pyconcept.py
Normal 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']
|
||||
})
|
29
rsconcept/backend/apps/rsform/serializers/schema_typing.py
Normal file
29
rsconcept/backend/apps/rsform/serializers/schema_typing.py
Normal 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()
|
177
rsconcept/backend/apps/rsform/tests/EndpointTester.py
Normal file
177
rsconcept/backend/apps/rsform/tests/EndpointTester.py
Normal 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
|
7
rsconcept/backend/apps/rsform/tests/__init__.py
Normal file
7
rsconcept/backend/apps/rsform/tests/__init__.py
Normal 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 *
|
6
rsconcept/backend/apps/rsform/tests/s_models/__init__.py
Normal file
6
rsconcept/backend/apps/rsform/tests/s_models/__init__.py
Normal 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 *
|
|
@ -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, '')
|
76
rsconcept/backend/apps/rsform/tests/s_models/t_Editor.py
Normal file
76
rsconcept/backend/apps/rsform/tests/s_models/t_Editor.py
Normal 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]))
|
103
rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py
Normal file
103
rsconcept/backend/apps/rsform/tests/s_models/t_LibraryItem.py
Normal 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/тест тест'))
|
364
rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py
Normal file
364
rsconcept/backend/apps/rsform/tests/s_models/t_RSForm.py
Normal 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, 'очень сильный слон')
|
|
@ -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))
|
9
rsconcept/backend/apps/rsform/tests/s_views/__init__.py
Normal file
9
rsconcept/backend/apps/rsform/tests/s_views/__init__.py
Normal 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 *
|
Binary file not shown.
33
rsconcept/backend/apps/rsform/tests/s_views/t_cctext.py
Normal file
33
rsconcept/backend/apps/rsform/tests/s_views/t_cctext.py
Normal 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'], 'синий слон')
|
103
rsconcept/backend/apps/rsform/tests/s_views/t_constituents.py
Normal file
103
rsconcept/backend/apps/rsform/tests/s_views/t_constituents.py
Normal 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)
|
403
rsconcept/backend/apps/rsform/tests/s_views/t_library.py
Normal file
403
rsconcept/backend/apps/rsform/tests/s_views/t_library.py
Normal 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)
|
83
rsconcept/backend/apps/rsform/tests/s_views/t_operations.py
Normal file
83
rsconcept/backend/apps/rsform/tests/s_views/t_operations.py
Normal 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')
|
518
rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py
Normal file
518
rsconcept/backend/apps/rsform/tests/s_views/t_rsforms.py
Normal 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[α,β])')
|
37
rsconcept/backend/apps/rsform/tests/s_views/t_rslang.py
Normal file
37
rsconcept/backend/apps/rsform/tests/s_views/t_rslang.py
Normal 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]]')
|
174
rsconcept/backend/apps/rsform/tests/s_views/t_versions.py
Normal file
174
rsconcept/backend/apps/rsform/tests/s_views/t_versions.py
Normal 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
|
115
rsconcept/backend/apps/rsform/tests/t_graph.py
Normal file
115
rsconcept/backend/apps/rsform/tests/t_graph.py
Normal 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])
|
142
rsconcept/backend/apps/rsform/tests/t_imports.py
Normal file
142
rsconcept/backend/apps/rsform/tests/t_imports.py
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}'''
|
22
rsconcept/backend/apps/rsform/tests/t_serializers.py
Normal file
22
rsconcept/backend/apps/rsform/tests/t_serializers.py
Normal 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))
|
25
rsconcept/backend/apps/rsform/tests/t_utils.py
Normal file
25
rsconcept/backend/apps/rsform/tests/t_utils.py
Normal 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}')
|
8
rsconcept/backend/apps/rsform/tests/testing_utils.py
Normal file
8
rsconcept/backend/apps/rsform/tests/testing_utils.py
Normal 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)
|
35
rsconcept/backend/apps/rsform/urls.py
Normal file
35
rsconcept/backend/apps/rsform/urls.py
Normal 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)),
|
||||
]
|
68
rsconcept/backend/apps/rsform/utils.py
Normal file
68
rsconcept/backend/apps/rsform/utils.py
Normal 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'
|
8
rsconcept/backend/apps/rsform/views/__init__.py
Normal file
8
rsconcept/backend/apps/rsform/views/__init__.py
Normal 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
|
70
rsconcept/backend/apps/rsform/views/cctext.py
Normal file
70
rsconcept/backend/apps/rsform/views/cctext.py
Normal 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}
|
||||
)
|
15
rsconcept/backend/apps/rsform/views/constituents.py
Normal file
15
rsconcept/backend/apps/rsform/views/constituents.py
Normal 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
|
313
rsconcept/backend/apps/rsform/views/library.py
Normal file
313
rsconcept/backend/apps/rsform/views/library.py
Normal 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)
|
50
rsconcept/backend/apps/rsform/views/operations.py
Normal file
50
rsconcept/backend/apps/rsform/views/operations.py
Normal 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
|
||||
)
|
492
rsconcept/backend/apps/rsform/views/rsforms.py
Normal file
492
rsconcept/backend/apps/rsform/views/rsforms.py
Normal 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)
|
71
rsconcept/backend/apps/rsform/views/rslang.py
Normal file
71
rsconcept/backend/apps/rsform/views/rslang.py
Normal 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}
|
||||
)
|
141
rsconcept/backend/apps/rsform/views/versions.py
Normal file
141
rsconcept/backend/apps/rsform/views/versions.py
Normal 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
|
0
rsconcept/backend/apps/users/__init__.py
Normal file
0
rsconcept/backend/apps/users/__init__.py
Normal file
1
rsconcept/backend/apps/users/admin.py
Normal file
1
rsconcept/backend/apps/users/admin.py
Normal file
|
@ -0,0 +1 @@
|
|||
''' Admin: User profile and Authorization. '''
|
11
rsconcept/backend/apps/users/apps.py
Normal file
11
rsconcept/backend/apps/users/apps.py
Normal 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
|
14
rsconcept/backend/apps/users/messages.py
Normal file
14
rsconcept/backend/apps/users/messages.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
''' Utility: Text messages. '''
|
||||
# pylint: skip-file
|
||||
|
||||
|
||||
def passwordAuthFailed():
|
||||
return 'Неизвестное сочетание имени пользователя (email) и пароля'
|
||||
|
||||
|
||||
def passwordsNotMatch():
|
||||
return 'Введенные пароли не совпадают'
|
||||
|
||||
|
||||
def emailAlreadyTaken():
|
||||
return 'Пользователь с данным email уже существует'
|
0
rsconcept/backend/apps/users/migrations/__init__.py
Normal file
0
rsconcept/backend/apps/users/migrations/__init__.py
Normal file
5
rsconcept/backend/apps/users/models.py
Normal file
5
rsconcept/backend/apps/users/models.py
Normal 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
|
169
rsconcept/backend/apps/users/serializers.py
Normal file
169
rsconcept/backend/apps/users/serializers.py
Normal 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
Loading…
Reference in New Issue
Block a user