├── .github └── ISSUE_TEMPLATE │ ├── bug.md │ └── feature.md ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── backend ├── .coveragerc ├── .dockerignore ├── Dockerfile ├── Pipfile ├── Pipfile.lock ├── backend │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── grafit │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── concept_extractor │ │ ├── README.md │ │ ├── __init__.py │ │ ├── extractor.py │ │ ├── resources │ │ │ ├── __init__.py │ │ │ └── grafit_public_article.csv │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── test_extractor.py │ ├── concept_runner.py │ ├── crawler │ │ ├── __init__.py │ │ ├── crawler.py │ │ ├── source.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── test_crawler.py │ ├── management │ │ └── commands │ │ │ ├── clear_neo4j.py │ │ │ └── install_labels.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_article.py │ │ ├── 0003_load_data.py │ │ ├── 0004_article_related.py │ │ ├── 0005_auto_20181029_1255.py │ │ ├── 0006_fix_ids.py │ │ ├── 0007_auto_20181109_1633.py │ │ ├── 0008_workspace.py │ │ ├── 0009_article_workspace.py │ │ ├── 0010_fix_ids2.py │ │ ├── 0011_remove_article_related.py │ │ ├── 0012_create_search_index.py │ │ ├── 0013_add_unique_to_searchindex.py │ │ ├── 0014_create_search_word_index.py │ │ ├── 0015_searchresult_searchword.py │ │ ├── 0016_add_webcontent_to_article.py │ │ ├── 0017_fix_null_search_index.py │ │ └── __init__.py │ ├── models.py │ ├── search │ │ ├── __init__.py │ │ ├── search.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── test_search.py │ ├── serializers.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_articles.py │ │ └── test_workspaces.py │ └── views.py ├── manage.py ├── wait_for_neo4j.py └── wait_for_postgres.py ├── docker-compose.build.yml ├── docker-compose.dev.yml ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── nginx.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo_192.png │ ├── logo_512.png │ ├── logo_transparent.svg │ └── manifest.json └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── components │ ├── AlertSnack.jsx │ ├── article │ │ ├── ArticleList.jsx │ │ ├── RelatedArticleBadges.jsx │ │ ├── articledetail │ │ │ ├── ArticleCreate.jsx │ │ │ ├── ArticleDetail.jsx │ │ │ ├── ArticleEdit.jsx │ │ │ └── ArticleUpdate.jsx │ │ ├── relatedarticletags │ │ │ ├── RelatedTags.jsx │ │ │ └── style.css │ │ └── relatedarticletree │ │ │ ├── RelatedArticleTree.jsx │ │ │ ├── TreebeadHeader.jsx │ │ │ └── treebeardstyles.js │ ├── login │ │ ├── Login.css │ │ ├── Login.jsx │ │ └── Register.jsx │ ├── navigation │ │ └── Navigation.jsx │ ├── search │ │ ├── SearchResultPopover.jsx │ │ ├── Searchbar.css │ │ └── Searchbar.jsx │ └── workspace │ │ ├── CreateWorkspace.jsx │ │ ├── UserDropdown.jsx │ │ ├── Workspace.css │ │ ├── Workspace.jsx │ │ └── WorkspaceToggle.jsx │ ├── constants.js │ ├── index.css │ ├── index.js │ ├── serviceWorker.js │ └── services │ ├── APIService.js │ └── AuthService.js ├── mkdocs.yml ├── mkdocs ├── Dockerfile ├── docs │ ├── developerdoc │ │ ├── api │ │ │ └── authentication.md │ │ ├── ci.md │ │ ├── containers.md │ │ ├── neo4j.md │ │ └── quickstart.md │ ├── img │ │ ├── DockerDeployment.png │ │ ├── articlecreated.png │ │ ├── color_logo_transparent.png │ │ ├── createworkspace_form.png │ │ ├── createworkspace_start.png │ │ ├── createworkspace_success.png │ │ ├── inputconnection.png │ │ ├── inputlabel.png │ │ ├── labelgrouping.png │ │ ├── login.png │ │ ├── newarticleform.png │ │ ├── search.png │ │ └── signup.png │ ├── index.md │ └── userdoc │ │ ├── createarticle.md │ │ ├── createworkspace.md │ │ ├── index.md │ │ ├── search.md │ │ └── signup.md ├── mkdocs.yml └── requirements.txt └── readthedocs.yml /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Description of problem** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Solution** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | 16 | **Criteria for acceptance** 17 | A clear and concise list of what needs to happen. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .pytest_cache 3 | *.pyc 4 | /backend/static 5 | *.sqlite3 6 | *.sqlite3-journal 7 | .idea 8 | .swp 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | python: 4 | - "3.6" 5 | services: 6 | - docker 7 | 8 | cache: 9 | - pip 10 | - directories: 11 | - frontend/node_modules 12 | 13 | env: 14 | - DOCKER_COMPOSE_VERSION=1.17.1 15 | 16 | before_install: 17 | - sudo rm /usr/local/bin/docker-compose 18 | - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose 19 | - chmod +x docker-compose 20 | - sudo mv docker-compose /usr/local/bin 21 | - "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash" 22 | - docker -v 23 | - docker-compose -v 24 | 25 | install: 26 | - pip install codecov 27 | 28 | script: 29 | - docker-compose -f docker-compose.dev.yml build 30 | - docker-compose -f docker-compose.dev.yml run --rm backend bash -c "python wait_for_postgres.py && python wait_for_neo4j.py && coverage run ./manage.py test && coverage xml -i" 31 | - docker-compose -f docker-compose.dev.yml run -e CI=true --rm frontend npm test -- --coverage 32 | - docker-compose rm -f 33 | 34 | - docker-compose -f docker-compose.build.yml build 35 | 36 | - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin 37 | 38 | - docker tag grafit-frontend:latest grafitio/grafit-frontend 39 | - docker push grafitio/grafit-frontend 40 | 41 | - docker tag grafit-backend:latest grafitio/grafit-backend 42 | - docker push grafitio/grafit-backend 43 | 44 | - docker tag grafit-docs:latest grafitio/grafit-docs 45 | - docker push grafitio/grafit-docs 46 | 47 | after_success: 48 | - cd backend && codecov -F backend && cd ../ 49 | - cd frontend && codecov -F frontend && cd ../ 50 | - fossa 51 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at grafitio@protonmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute :rocket: 2 | 3 | Thank you for contributing! 4 | 5 | By participating in this project, you agree to abide by the code of conduct. 6 | 7 | Before we can merge your Pull-Request here are some guidelines that you need to follow. 8 | These guidelines exist not to annoy you, but to keep the code base clean, unified and future proof. 9 | 10 | ## Unit-Tests :umbrella: 11 | 12 | Please try to add a test for your pull-request. 13 | This project uses [Django](https://www.djangoproject.com/) on the backend and you can run tests with the following command: 14 | ``` 15 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py test 16 | ``` 17 | 18 | 19 | ## Documentation :notebook: 20 | User and developer documentation is available [here](https://grafit.readthedocs.io/en/latest/). 21 | UI-Guidelines are available [here](https://brandguidelines.logojoy.com/grafit-io). 22 | 23 | ## CI :construction_worker: 24 | 25 | We automatically run your pull request through [Travis CI](https://www.travis-ci.org) and [Codecov](https://codecov.io/). 26 | 27 | ## Issues and Bugs :bug: 28 | 29 | To create a new issue, you can use the GitHub issue tracking system. 30 | We use the [Issue.sh GitHub Extension](https://issue.sh/) to get a better Issue Board with Issue Dependencies. 31 | 32 | ## Getting merged :checkered_flag: 33 | 34 | Please allow us time to review your pull requests. 35 | We will give our best to review everything as fast as possible, but cannot always live up to our own expectations. 36 | 37 | We try to follow a coherent [commit message style](https://karma-runner.github.io/2.0/dev/git-commit-msg.html) laid out by Karama Runner. 38 | 39 | Pull requests without tests most probably will not be merged. 40 | Documentation PRs obviously do not require tests. 41 | 42 | Thank you very much again for your contribution! 43 | 44 | If you habe any questions, you can drop a mail to grafitio@protonmail.com 45 | 46 | 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 grafit.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![grafit logo](https://github.com/grafit-io/grafit/blob/master/mkdocs/docs/img/color_logo_transparent.png) 2 | 3 | [![Build Status](https://travis-ci.com/grafit-io/grafit.svg?branch=master)](https://travis-ci.com/grafit-io/grafit) 4 | [![codecov](https://codecov.io/gh/grafit-io/grafit/branch/master/graph/badge.svg)](https://codecov.io/gh/grafit-io/grafit) 5 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit?ref=badge_shield) 6 | [![RTD Status](https://readthedocs.org/projects/grafit/badge/?version=latest)](https://readthedocs.org/projects/grafit/) 7 | 8 | grafit is an MIT-licensed web app that allows teams to store, share and search knowledge in an effective way. With intelligent relation detection, different data sources are presented and searchable at one central entry point. 9 | 10 | ## Features 11 | 12 | :memo: store text in nodes 13 | :collision: text is analysed and relations to other nodes are learned 14 | :satellite: urls within the text are crawled and the content is saved 15 | :mag: full-text search 16 | :busts_in_silhouette: workspaces for managing access to group of nodes 17 | 18 | ## Documentation :notebook: 19 | 20 | User and developer documentation is available [here](https://grafit.readthedocs.io/en/latest/). 21 | UI-Guidelines are available [here](https://brandguidelines.logojoy.com/grafit-io). 22 | 23 | ## Getting started 24 | 25 | Install Docker [Mac](https://docs.docker.com/docker-for-mac/install/) / [Windows](https://docs.docker.com/docker-for-windows/install/) 26 | 27 | Clone this repo and run: 28 | 29 | ```bash 30 | docker-compose up 31 | ``` 32 | 33 | Go to [http://localhost:8001](http://localhost:8001) to read the docs. 34 | 35 | Go to [http://localhost:3000](http://localhost:3000) to start the app. 36 | 37 | ## Contribute 38 | 39 | Berfore contributing, please have a look at our [Code Of Conduct](CODE_OF_CONDUCT.md) and the [Contributing Guidelines](CONTRIBUTING.md). 40 | 41 | ## License 42 | 43 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit?ref=badge_large) 44 | -------------------------------------------------------------------------------- /backend/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = backend,grafit 3 | omit = 4 | */tests/* 5 | */site-packages/* 6 | */migrations/* 7 | *wsgi.py -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile* 4 | docker-compose* 5 | .dockerignore 6 | .git 7 | .gitignore 8 | .env 9 | */bin 10 | */obj 11 | README.md 12 | LICENSE 13 | .vscode -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-slim as base 2 | 3 | LABEL Name=grafit Version=0.0.1 4 | EXPOSE 8000 5 | 6 | # Using pipenv: 7 | COPY ./Pipfile Pipfile 8 | COPY ./Pipfile.lock Pipfile.lock 9 | RUN pip install pipenv 10 | RUN pipenv install --system --deploy --ignore-pipfile 11 | RUN python -m textblob.download_corpora 12 | 13 | COPY . /code 14 | WORKDIR /code 15 | 16 | # Migrates the database, uploads staticfiles 17 | CMD python wait_for_postgres.py && \ 18 | python wait_for_neo4j.py && \ 19 | ./manage.py install_labels && \ 20 | ./manage.py migrate && \ 21 | ./manage.py collectstatic --noinput && \ 22 | ./manage.py runserver 0.0.0.0:8000 23 | 24 | FROM base as prod 25 | CMD python wait_for_postgres.py && \ 26 | python wait_for_neo4j.py && \ 27 | ./manage.py install_labels && \ 28 | ./manage.py migrate && \ 29 | ./manage.py collectstatic --noinput && \ 30 | gunicorn --bind 0.0.0.0:8000 --access-logfile - backend.wsgi:application 31 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | pylint = "*" 9 | "autopep8" = "*" 10 | 11 | [packages] 12 | textblob = "*" 13 | djangorestframework = "*" 14 | django = "*" 15 | markdown = "*" 16 | django-filter = "*" 17 | gunicorn = "*" 18 | whitenoise = "*" 19 | "psycopg2-binary" = "*" 20 | django-cors-headers = "*" 21 | djangorestframework-timed-auth-token = "*" 22 | neomodel = "==3.2.9" 23 | coverage = "*" 24 | html2text = "*" 25 | requests = "*" 26 | 27 | [requires] 28 | python_version = "3.6" 29 | -------------------------------------------------------------------------------- /backend/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "515b3b538a316c8ebfb37ed4d6241c06f62637c65f449c475d6c149499e26624" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", 22 | "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" 23 | ], 24 | "version": "==2018.11.29" 25 | }, 26 | "chardet": { 27 | "hashes": [ 28 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 29 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 30 | ], 31 | "version": "==3.0.4" 32 | }, 33 | "coverage": { 34 | "hashes": [ 35 | "sha256:09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f", 36 | "sha256:0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe", 37 | "sha256:0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d", 38 | "sha256:10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0", 39 | "sha256:1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607", 40 | "sha256:1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d", 41 | "sha256:2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b", 42 | "sha256:447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3", 43 | "sha256:46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e", 44 | "sha256:4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815", 45 | "sha256:510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36", 46 | "sha256:5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1", 47 | "sha256:5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14", 48 | "sha256:5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c", 49 | "sha256:6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794", 50 | "sha256:6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b", 51 | "sha256:77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840", 52 | "sha256:828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd", 53 | "sha256:85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82", 54 | "sha256:8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952", 55 | "sha256:a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389", 56 | "sha256:aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f", 57 | "sha256:ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4", 58 | "sha256:b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da", 59 | "sha256:bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647", 60 | "sha256:c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d", 61 | "sha256:d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42", 62 | "sha256:d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478", 63 | "sha256:da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b", 64 | "sha256:ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb", 65 | "sha256:ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9" 66 | ], 67 | "index": "pypi", 68 | "version": "==4.5.2" 69 | }, 70 | "django": { 71 | "hashes": [ 72 | "sha256:a32c22af23634e1d11425574dce756098e015a165be02e4690179889b207c7a8", 73 | "sha256:d6393918da830530a9516bbbcbf7f1214c3d733738779f06b0f649f49cc698c3" 74 | ], 75 | "index": "pypi", 76 | "version": "==2.1.5" 77 | }, 78 | "django-cors-headers": { 79 | "hashes": [ 80 | "sha256:5545009c9b233ea7e70da7dbab7cb1c12afa01279895086f98ec243d7eab46fa", 81 | "sha256:c4c2ee97139d18541a1be7d96fe337d1694623816d83f53cb7c00da9b94acae1" 82 | ], 83 | "index": "pypi", 84 | "version": "==2.4.0" 85 | }, 86 | "django-filter": { 87 | "hashes": [ 88 | "sha256:6f4e4bc1a11151178520567b50320e5c32f8edb552139d93ea3e30613b886f56", 89 | "sha256:86c3925020c27d072cdae7b828aaa5d165c2032a629abbe3c3a1be1edae61c58" 90 | ], 91 | "index": "pypi", 92 | "version": "==2.0.0" 93 | }, 94 | "djangorestframework": { 95 | "hashes": [ 96 | "sha256:607865b0bb1598b153793892101d881466bd5a991de12bd6229abb18b1c86136", 97 | "sha256:63f76cbe1e7d12b94c357d7e54401103b2e52aef0f7c1650d6c820ad708776e5" 98 | ], 99 | "index": "pypi", 100 | "version": "==3.9.0" 101 | }, 102 | "djangorestframework-timed-auth-token": { 103 | "hashes": [ 104 | "sha256:31a0c2757ef8dc3bf1ff50adeaa78a4ceffeb46d743ae0d611b69c487362c565" 105 | ], 106 | "index": "pypi", 107 | "version": "==1.3.0" 108 | }, 109 | "gunicorn": { 110 | "hashes": [ 111 | "sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", 112 | "sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" 113 | ], 114 | "index": "pypi", 115 | "version": "==19.9.0" 116 | }, 117 | "html2text": { 118 | "hashes": [ 119 | "sha256:490db40fe5b2cd79c461cf56be4d39eb8ca68191ae41ba3ba79f6cb05b7dd662", 120 | "sha256:627514fb30e7566b37be6900df26c2c78a030cc9e6211bda604d8181233bcdd4" 121 | ], 122 | "index": "pypi", 123 | "version": "==2018.1.9" 124 | }, 125 | "idna": { 126 | "hashes": [ 127 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 128 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 129 | ], 130 | "version": "==2.8" 131 | }, 132 | "markdown": { 133 | "hashes": [ 134 | "sha256:c00429bd503a47ec88d5e30a751e147dcb4c6889663cd3e2ba0afe858e009baa", 135 | "sha256:d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c" 136 | ], 137 | "index": "pypi", 138 | "version": "==3.0.1" 139 | }, 140 | "neo4j-driver": { 141 | "hashes": [ 142 | "sha256:6a3872f28d34e711e2f6335ae3f4ff10bae338b4986d3f0fe46c592ca1820877" 143 | ], 144 | "version": "==1.6.2" 145 | }, 146 | "neomodel": { 147 | "hashes": [ 148 | "sha256:2f7cd8aa60a0069d3cde4938581bfafc690d03820b48cf93aa727b6ec4bd923b" 149 | ], 150 | "index": "pypi", 151 | "version": "==3.2.9" 152 | }, 153 | "neotime": { 154 | "hashes": [ 155 | "sha256:849f2e7a67204d67d4cb57c523b6e1cf4d36baf105197294fee6585e76ebc632" 156 | ], 157 | "version": "==1.0.0" 158 | }, 159 | "nltk": { 160 | "hashes": [ 161 | "sha256:286f6797204ffdb52525a1d21ec0a221ec68b8e3fa4f2d25f412ac8e63c70e8d" 162 | ], 163 | "version": "==3.4" 164 | }, 165 | "psycopg2-binary": { 166 | "hashes": [ 167 | "sha256:036bcb198a7cc4ce0fe43344f8c2c9a8155aefa411633f426c8c6ed58a6c0426", 168 | "sha256:1d770fcc02cdf628aebac7404d56b28a7e9ebec8cfc0e63260bd54d6edfa16d4", 169 | "sha256:1fdc6f369dcf229de6c873522d54336af598b9470ccd5300e2f58ee506f5ca13", 170 | "sha256:21f9ddc0ff6e07f7d7b6b484eb9da2c03bc9931dd13e36796b111d631f7135a3", 171 | "sha256:247873cda726f7956f745a3e03158b00de79c4abea8776dc2f611d5ba368d72d", 172 | "sha256:3aa31c42f29f1da6f4fd41433ad15052d5ff045f2214002e027a321f79d64e2c", 173 | "sha256:475f694f87dbc619010b26de7d0fc575a4accf503f2200885cc21f526bffe2ad", 174 | "sha256:4b5e332a24bf6e2fda1f51ca2a57ae1083352293a08eeea1fa1112dc7dd542d1", 175 | "sha256:570d521660574aca40be7b4d532dfb6f156aad7b16b5ed62d1534f64f1ef72d8", 176 | "sha256:59072de7def0690dd13112d2bdb453e20570a97297070f876fbbb7cbc1c26b05", 177 | "sha256:5f0b658989e918ef187f8a08db0420528126f2c7da182a7b9f8bf7f85144d4e4", 178 | "sha256:649199c84a966917d86cdc2046e03d536763576c0b2a756059ae0b3a9656bc20", 179 | "sha256:6645fc9b4705ae8fbf1ef7674f416f89ae1559deec810f6dd15197dfa52893da", 180 | "sha256:6872dd54d4e398d781efe8fe2e2d7eafe4450d61b5c4898aced7610109a6df75", 181 | "sha256:6ce34fbc251fc0d691c8d131250ba6f42fd2b28ef28558d528ba8c558cb28804", 182 | "sha256:73920d167a0a4d1006f5f3b9a3efce6f0e5e883a99599d38206d43f27697df00", 183 | "sha256:8a671732b87ae423e34b51139628123bc0306c2cb85c226e71b28d3d57d7e42a", 184 | "sha256:8d517e8fda2efebca27c2018e14c90ed7dc3f04d7098b3da2912e62a1a5585fe", 185 | "sha256:9475a008eb7279e20d400c76471843c321b46acacc7ee3de0b47233a1e3fa2cf", 186 | "sha256:96947b8cd7b3148fb0e6549fcb31258a736595d6f2a599f8cd450e9a80a14781", 187 | "sha256:abf229f24daa93f67ac53e2e17c8798a71a01711eb9fcdd029abba8637164338", 188 | "sha256:b1ab012f276df584beb74f81acb63905762c25803ece647016613c3d6ad4e432", 189 | "sha256:b22b33f6f0071fe57cb4e9158f353c88d41e739a3ec0d76f7b704539e7076427", 190 | "sha256:b3b2d53274858e50ad2ffdd6d97ce1d014e1e530f82ec8b307edd5d4c921badf", 191 | "sha256:bab26a729befc7b9fab9ded1bba9c51b785188b79f8a2796ba03e7e734269e2e", 192 | "sha256:daa1a593629aa49f506eddc9d23dc7f89b35693b90e1fbcd4480182d1203ea90", 193 | "sha256:dd111280ce40e89fd17b19c1269fd1b74a30fce9d44a550840e86edb33924eb8", 194 | "sha256:e0b86084f1e2e78c451994410de756deba206884d6bed68d5a3d7f39ff5fea1d", 195 | "sha256:eb86520753560a7e89639500e2a254bb6f683342af598088cb72c73edcad21e6", 196 | "sha256:ff18c5c40a38d41811c23e2480615425c97ea81fd7e9118b8b899c512d97c737" 197 | ], 198 | "index": "pypi", 199 | "version": "==2.7.6.1" 200 | }, 201 | "pytz": { 202 | "hashes": [ 203 | "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", 204 | "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" 205 | ], 206 | "version": "==2018.9" 207 | }, 208 | "requests": { 209 | "hashes": [ 210 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", 211 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" 212 | ], 213 | "index": "pypi", 214 | "version": "==2.21.0" 215 | }, 216 | "singledispatch": { 217 | "hashes": [ 218 | "sha256:5b06af87df13818d14f08a028e42f566640aef80805c3b50c5056b086e3c2b9c", 219 | "sha256:833b46966687b3de7f438c761ac475213e53b306740f1abfaa86e1d1aae56aa8" 220 | ], 221 | "version": "==3.4.0.3" 222 | }, 223 | "six": { 224 | "hashes": [ 225 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 226 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 227 | ], 228 | "version": "==1.12.0" 229 | }, 230 | "textblob": { 231 | "hashes": [ 232 | "sha256:574f30be68a5afcc9f0ccfad088394833343a611c4f0a20473622e07c02cac0f", 233 | "sha256:fe607b2d19b8cb490c09cc4b09e3454e08ce9860359cc3eabcc5c2f18eec856c" 234 | ], 235 | "index": "pypi", 236 | "version": "==0.15.2" 237 | }, 238 | "urllib3": { 239 | "hashes": [ 240 | "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", 241 | "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" 242 | ], 243 | "version": "==1.24.1" 244 | }, 245 | "whitenoise": { 246 | "hashes": [ 247 | "sha256:118ab3e5f815d380171b100b05b76de2a07612f422368a201a9ffdeefb2251c1", 248 | "sha256:42133ddd5229eeb6a0c9899496bdbe56c292394bf8666da77deeb27454c0456a" 249 | ], 250 | "index": "pypi", 251 | "version": "==4.1.2" 252 | } 253 | }, 254 | "develop": { 255 | "astroid": { 256 | "hashes": [ 257 | "sha256:35b032003d6a863f5dcd7ec11abd5cd5893428beaa31ab164982403bcb311f22", 258 | "sha256:6a5d668d7dc69110de01cdf7aeec69a679ef486862a0850cc0fd5571505b6b7e" 259 | ], 260 | "version": "==2.1.0" 261 | }, 262 | "atomicwrites": { 263 | "hashes": [ 264 | "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", 265 | "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" 266 | ], 267 | "version": "==1.2.1" 268 | }, 269 | "attrs": { 270 | "hashes": [ 271 | "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", 272 | "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" 273 | ], 274 | "version": "==18.2.0" 275 | }, 276 | "autopep8": { 277 | "hashes": [ 278 | "sha256:33d2b5325b7e1afb4240814fe982eea3a92ebea712869bfd08b3c0393404248c" 279 | ], 280 | "index": "pypi", 281 | "version": "==1.4.3" 282 | }, 283 | "isort": { 284 | "hashes": [ 285 | "sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", 286 | "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", 287 | "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497" 288 | ], 289 | "version": "==4.3.4" 290 | }, 291 | "lazy-object-proxy": { 292 | "hashes": [ 293 | "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", 294 | "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", 295 | "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", 296 | "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", 297 | "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", 298 | "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", 299 | "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", 300 | "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", 301 | "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", 302 | "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", 303 | "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", 304 | "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", 305 | "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", 306 | "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", 307 | "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", 308 | "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", 309 | "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", 310 | "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", 311 | "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", 312 | "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", 313 | "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", 314 | "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", 315 | "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", 316 | "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", 317 | "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", 318 | "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", 319 | "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", 320 | "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", 321 | "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" 322 | ], 323 | "version": "==1.3.1" 324 | }, 325 | "mccabe": { 326 | "hashes": [ 327 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 328 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 329 | ], 330 | "version": "==0.6.1" 331 | }, 332 | "more-itertools": { 333 | "hashes": [ 334 | "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", 335 | "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", 336 | "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9" 337 | ], 338 | "version": "==5.0.0" 339 | }, 340 | "pluggy": { 341 | "hashes": [ 342 | "sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", 343 | "sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f" 344 | ], 345 | "version": "==0.8.0" 346 | }, 347 | "py": { 348 | "hashes": [ 349 | "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", 350 | "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" 351 | ], 352 | "version": "==1.7.0" 353 | }, 354 | "pycodestyle": { 355 | "hashes": [ 356 | "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", 357 | "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" 358 | ], 359 | "version": "==2.4.0" 360 | }, 361 | "pylint": { 362 | "hashes": [ 363 | "sha256:689de29ae747642ab230c6d37be2b969bf75663176658851f456619aacf27492", 364 | "sha256:771467c434d0d9f081741fec1d64dfb011ed26e65e12a28fe06ca2f61c4d556c" 365 | ], 366 | "index": "pypi", 367 | "version": "==2.2.2" 368 | }, 369 | "pytest": { 370 | "hashes": [ 371 | "sha256:3e65a22eb0d4f1bdbc1eacccf4a3198bf8d4049dea5112d70a0c61b00e748d02", 372 | "sha256:5924060b374f62608a078494b909d341720a050b5224ff87e17e12377486a71d" 373 | ], 374 | "index": "pypi", 375 | "version": "==4.1.0" 376 | }, 377 | "six": { 378 | "hashes": [ 379 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 380 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 381 | ], 382 | "version": "==1.12.0" 383 | }, 384 | "typed-ast": { 385 | "hashes": [ 386 | "sha256:0555eca1671ebe09eb5f2176723826f6f44cca5060502fea259de9b0e893ab53", 387 | "sha256:0ca96128ea66163aea13911c9b4b661cb345eb729a20be15c034271360fc7474", 388 | "sha256:16ccd06d614cf81b96de42a37679af12526ea25a208bce3da2d9226f44563868", 389 | "sha256:1e21ae7b49a3f744958ffad1737dfbdb43e1137503ccc59f4e32c4ac33b0bd1c", 390 | "sha256:37670c6fd857b5eb68aa5d193e14098354783b5138de482afa401cc2644f5a7f", 391 | "sha256:46d84c8e3806619ece595aaf4f37743083f9454c9ea68a517f1daa05126daf1d", 392 | "sha256:5b972bbb3819ece283a67358103cc6671da3646397b06e7acea558444daf54b2", 393 | "sha256:6306ffa64922a7b58ee2e8d6f207813460ca5a90213b4a400c2e730375049246", 394 | "sha256:6cb25dc95078931ecbd6cbcc4178d1b8ae8f2b513ae9c3bd0b7f81c2191db4c6", 395 | "sha256:7e19d439fee23620dea6468d85bfe529b873dace39b7e5b0c82c7099681f8a22", 396 | "sha256:7f5cd83af6b3ca9757e1127d852f497d11c7b09b4716c355acfbebf783d028da", 397 | "sha256:81e885a713e06faeef37223a5b1167615db87f947ecc73f815b9d1bbd6b585be", 398 | "sha256:94af325c9fe354019a29f9016277c547ad5d8a2d98a02806f27a7436b2da6735", 399 | "sha256:b1e5445c6075f509d5764b84ce641a1535748801253b97f3b7ea9d948a22853a", 400 | "sha256:cb061a959fec9a514d243831c514b51ccb940b58a5ce572a4e209810f2507dcf", 401 | "sha256:cc8d0b703d573cbabe0d51c9d68ab68df42a81409e4ed6af45a04a95484b96a5", 402 | "sha256:da0afa955865920edb146926455ec49da20965389982f91e926389666f5cf86a", 403 | "sha256:dc76738331d61818ce0b90647aedde17bbba3d3f9e969d83c1d9087b4f978862", 404 | "sha256:e7ec9a1445d27dbd0446568035f7106fa899a36f55e52ade28020f7b3845180d", 405 | "sha256:f741ba03feb480061ab91a465d1a3ed2d40b52822ada5b4017770dfcb88f839f", 406 | "sha256:fe800a58547dd424cd286b7270b967b5b3316b993d86453ede184a17b5a6b17d" 407 | ], 408 | "markers": "python_version < '3.7' and implementation_name == 'cpython'", 409 | "version": "==1.1.1" 410 | }, 411 | "wrapt": { 412 | "hashes": [ 413 | "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" 414 | ], 415 | "version": "==1.10.11" 416 | } 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /backend/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/backend/__init__.py -------------------------------------------------------------------------------- /backend/backend/settings.py: -------------------------------------------------------------------------------- 1 | from neomodel import config 2 | """ 3 | Django settings for backend project. 4 | 5 | Generated by 'django-admin startproject' using Django 2.1.2. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/2.1/topics/settings/ 9 | 10 | For the full list of settings and their values, see 11 | https://docs.djangoproject.com/en/2.1/ref/settings/ 12 | """ 13 | 14 | import os 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '0jhlvrzdzpugqw_zrdujuvz9(i&nwsh$#(lmgacnykiw6s1f=w' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | config.DATABASE_URL = 'bolt://neo4j:test@neo4j:7687' 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'rest_framework', 42 | 'timed_auth_token', 43 | 'corsheaders', 44 | 'grafit', 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | 'corsheaders.middleware.CorsMiddleware', 49 | 'django.middleware.security.SecurityMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | 'django.middleware.common.CommonMiddleware', 57 | 'whitenoise.middleware.WhiteNoiseMiddleware', 58 | ] 59 | 60 | ROOT_URLCONF = 'backend.urls' 61 | 62 | TEMPLATES = [ 63 | { 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'DIRS': [], 66 | 'APP_DIRS': True, 67 | 'OPTIONS': { 68 | 'context_processors': [ 69 | 'django.template.context_processors.debug', 70 | 'django.template.context_processors.request', 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.contrib.messages.context_processors.messages', 73 | ], 74 | }, 75 | }, 76 | ] 77 | 78 | WSGI_APPLICATION = 'backend.wsgi.application' 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 82 | 83 | DATABASES = { 84 | 'default': { 85 | 'ENGINE': 'django.db.backends.postgresql', 86 | 'NAME': 'postgres', 87 | 'USER': 'postgres', 88 | 'HOST': 'db', 89 | 'PORT': 5432, 90 | } 91 | } 92 | 93 | AUTH_USER_MODEL = "grafit.User" 94 | 95 | # Password validation 96 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 97 | 98 | AUTH_PASSWORD_VALIDATORS = [ 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 110 | }, 111 | ] 112 | 113 | # Internationalization 114 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 115 | 116 | LANGUAGE_CODE = 'en-us' 117 | 118 | TIME_ZONE = 'UTC' 119 | 120 | USE_I18N = True 121 | 122 | USE_L10N = True 123 | 124 | USE_TZ = True 125 | 126 | # Static files (CSS, JavaScript, Images) 127 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 128 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 129 | STATICFILES_DIRS = [] 130 | STATIC_URL = '/api/static/' 131 | STATICFILES_FINDERS = ( 132 | 'django.contrib.staticfiles.finders.FileSystemFinder', 133 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 134 | ) 135 | 136 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 137 | 138 | # Django Rest Framework 139 | REST_FRAMEWORK = { 140 | 'DEFAULT_RENDERER_CLASSES': ( 141 | 'rest_framework.renderers.JSONRenderer', 142 | 'rest_framework.renderers.BrowsableAPIRenderer', 143 | ), 144 | 'DEFAULT_PERMISSION_CLASSES': [ 145 | 'rest_framework.permissions.IsAuthenticated', 146 | ], 147 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 148 | 'rest_framework.authentication.SessionAuthentication', 149 | 'timed_auth_token.authentication.TimedAuthTokenAuthentication', 150 | ), 151 | 'PAGE_SIZE': 25 152 | } 153 | 154 | CORS_ORIGIN_WHITELIST = ( 155 | 'localhost:3000' 156 | ) 157 | 158 | CORS_ALLOW_CREDENTIALS = True 159 | -------------------------------------------------------------------------------- /backend/backend/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include, re_path, reverse_lazy 3 | from django.views.generic.base import RedirectView 4 | from grafit import views 5 | from rest_framework import routers 6 | from timed_auth_token.views import TimedAuthTokenCreateView 7 | 8 | router = routers.DefaultRouter() 9 | router.register(r'users', views.UserViewSet) 10 | router.register(r'users', views.UserCreateViewSet) 11 | router.register(r'groups', views.GroupViewSet) 12 | router.register(r'articles', views.ArticleViewSet, basename="article") 13 | router.register(r'articletitless', views.ArticleTitleViewSet, basename="articletitle") 14 | router.register(r'workspaces', views.WorkspaceViewSet, basename="workspace") 15 | router.register(r'search', views.SearchResultViewSet, basename="search") 16 | 17 | 18 | urlpatterns = [ 19 | path('api-token-auth/', TimedAuthTokenCreateView.as_view()), 20 | path('admin/', admin.site.urls), 21 | path('api/v1/', include(router.urls)), 22 | path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), 23 | path('api/v1/runner', views.ConceptRunnerAPI.as_view()), 24 | path('api/v1/hideconcept', views.HideConceptAPI.as_view()), 25 | path('api/v1/addconcept', views.AddConceptAPI.as_view()), 26 | 27 | # the 'api-root' from django rest-frameworks default router 28 | # http://www.django-rest-framework.org/api-guide/routers/#defaultrouter 29 | re_path(r'^$', RedirectView.as_view( 30 | url=reverse_lazy('api-root'), permanent=False)), 31 | ] 32 | -------------------------------------------------------------------------------- /backend/backend/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for backend project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/grafit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/__init__.py -------------------------------------------------------------------------------- /backend/grafit/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from .models import User, Article, Workspace 5 | 6 | 7 | @admin.register(User) 8 | class UserAdmin(UserAdmin): 9 | pass 10 | 11 | 12 | @admin.register(Article) 13 | class ArticleAdmin(admin.ModelAdmin): 14 | list_display = ('id', 'title', 'updated_at', 'created_at',) 15 | ordering = ('updated_at',) 16 | pass 17 | 18 | 19 | @admin.register(Workspace) 20 | class WorkspaceAdmin(admin.ModelAdmin): 21 | pass 22 | -------------------------------------------------------------------------------- /backend/grafit/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GrafitConfig(AppConfig): 5 | name = 'grafit' 6 | -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/README.md: -------------------------------------------------------------------------------- 1 | # Concept Extractor 2 | 3 | Install Dependencies 4 | ``` 5 | pipenv install 6 | pipenv shell 7 | ``` 8 | 9 | Run Tests 10 | ``` 11 | PYTHONPATH=./concept_extractor pytest 12 | ``` -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/concept_extractor/__init__.py -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/extractor.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import csv 3 | import math 4 | 5 | from pkg_resources import resource_string 6 | from textblob import TextBlob as tb 7 | 8 | from ..models import Article 9 | 10 | stopwords = set( 11 | ["’", "−", "—", "'s", "a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", 12 | "aren't", "as", "at", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", 13 | "cannot", "could", "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", 14 | "each", "few", "for", "from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", 15 | "he'd", "he'll", "he's", "her", "here", "here's", "hers", "herself", "him", "himself", "his", "how", "how's", "i", 16 | "i'd", "i'll", "i'm", "i've", "if", "in", "into", "is", "isn't", "it", "it's", "its", "itself", "let's", "me", 17 | "more", "most", "mustn't", "my", "myself", "no", "nor", "not", "of", "off", 18 | "on", "once", "only", "or", "other", "ought", "our", "ours" "ourselves", "out", "over", "own", "same", "shan't", 19 | "she", "she'd", "she'll", "she's", "should", "shouldn't", "so", "some", "such", "than", "that", "that's", "the", 20 | "their", "theirs", "them", "themselves", "then", "there", "there's", "these", "they", "they'd", "they'll", 21 | "they're", "they've", "this", "those", "through", "to", "too", "under", "until", "up", "very", "was", "wasn't", 22 | "we", "we'd", "we'll", "we're", "we've", "were", "weren't", "what", "what's", "when", "when's", "where", "where's", 23 | "which", "while", "who", "who's", "whom", "why", "why's", "with", "won't", "would", "wouldn't", "you", "you'd", 24 | "you'll", "you're", "you've", "your", "yours", "yourself", "yourselves"]) 25 | 26 | 27 | class ExtractStrategyAbstract(object): 28 | """Abstract strategy class for the extract method.""" 29 | 30 | __metaclass__ = abc.ABCMeta # define as absctract class 31 | 32 | @abc.abstractmethod 33 | def extract_keyphrases(self, text: str): 34 | """Required Method""" 35 | 36 | 37 | class FakeExtractStrategy(ExtractStrategyAbstract): 38 | def extract_keyphrases(self, text: str): 39 | return ["test"] 40 | 41 | 42 | class TextblobTfIdfKeyphraseExtractStrategy(ExtractStrategyAbstract): 43 | pass 44 | 45 | 46 | class TextblobTfIdfExtractStrategy(ExtractStrategyAbstract): 47 | 48 | def __init__(self, loadFromDB=True): 49 | if loadFromDB: 50 | self.corpus = self.load_corpus_db() 51 | else: 52 | self.corpus = self.load_corpus() 53 | 54 | def tf(self, word, blob): 55 | return blob.words.count(word) / len(blob.words) 56 | 57 | def n_containing(self, word, corpus): 58 | return sum(1 for blob in corpus if word in blob.words) 59 | 60 | def idf(self, word, corpus): 61 | return math.log(len(corpus) / (1 + self.n_containing(word, corpus))) 62 | 63 | def tfidf(self, word, blob, corpus): 64 | return self.tf(word, blob) * self.idf(word, corpus) 65 | 66 | def load_corpus(self): 67 | raw_csv = resource_string( 68 | 'grafit.concept_extractor.resources', 'grafit_public_article.csv').decode('utf-8').splitlines() 69 | results = list(csv.reader(raw_csv, delimiter=',')) 70 | 71 | tbs = [] 72 | for result in results: 73 | tbs.append(tb(result[1] + " " + result[2])) 74 | 75 | return tbs 76 | 77 | def load_corpus_db(self): 78 | articles = Article.objects.all() 79 | 80 | tbs = [] 81 | for article in articles: 82 | tbs.append(tb(article.title.lower() + " " + article.text.lower())) 83 | 84 | return tbs 85 | 86 | def extract_keyphrases(self, text: str, top_n_words=5): 87 | result = [] 88 | blob = tb(text.lower()) 89 | 90 | scores = {word: self.tfidf(word, blob, self.corpus) 91 | for word in blob.words} 92 | sorted_words = sorted(scores.items(), key=lambda x: x[1], reverse=True) 93 | for word, score in sorted_words: 94 | if word.lower() not in stopwords: 95 | result.append({ 96 | "word": word, 97 | "tf-idf": score 98 | }) 99 | 100 | return result[:top_n_words] 101 | -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/concept_extractor/resources/__init__.py -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/concept_extractor/tests/__init__.py -------------------------------------------------------------------------------- /backend/grafit/concept_extractor/tests/test_extractor.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from grafit.concept_extractor.extractor import FakeExtractStrategy, TextblobTfIdfExtractStrategy 3 | 4 | 5 | class ExtractorTest(TestCase): 6 | 7 | text = """ 8 | Vulkan Ollagüe (Spanish pronunciation: [oˈʝaɣwe]) or Ullawi 9 | (Aymara pronunciation: [uˈʎawi]) is a massive andesite stratovolcano Vulkan in the 10 | Andes on the border between Bolivia and Chile, within the Antofagasta Region of 11 | Chile and the Potosi Department of Bolivia. Part of the Central Volcanic Zone of the 12 | Andes, its highest summit is 5,868 metres (19,252 ft) above sea level and features 13 | a summit crater that opens to the south. The western rim of the summit crater is 14 | formed by a compound of lava domes, the youngest of which features a vigorous fumarole 15 | that is visible from afar. The Vulkan... 16 | """ 17 | 18 | def test_function(self): 19 | assert FakeExtractStrategy().extract_keyphrases("testtestest") == ["test"] 20 | 21 | def test_tfidf_strategy_from_db(self): 22 | # test load corpus 23 | TextblobTfIdfExtractStrategy() 24 | 25 | def test_tfidf_strategy_from_csv(self): 26 | TextblobTfIdfExtractStrategy(loadFromDB=False) 27 | 28 | def test_tfidf_strategy_top_word(self): 29 | keyword_extractor = TextblobTfIdfExtractStrategy(loadFromDB=False) 30 | keywords = keyword_extractor.extract_keyphrases(self.text) 31 | assert keywords[0]['word'] == "vulkan" 32 | 33 | def test_tfidf_strategy_nr_keywords(self): 34 | keyword_extractor = TextblobTfIdfExtractStrategy(loadFromDB=False) 35 | keywords = keyword_extractor.extract_keyphrases(self.text, top_n_words=10) 36 | assert len(keywords) == 10 37 | -------------------------------------------------------------------------------- /backend/grafit/concept_runner.py: -------------------------------------------------------------------------------- 1 | from .concept_extractor.extractor import TextblobTfIdfExtractStrategy 2 | from .models import Article, GraphArticle 3 | import logging 4 | 5 | 6 | logger = logging.getLogger() 7 | logger.setLevel(logging.INFO) 8 | logger.addHandler(logging.StreamHandler()) 9 | 10 | 11 | class ConceptRunner: 12 | 13 | _tfidf_extractor = None 14 | 15 | @classmethod 16 | def _get_tfidf_extractor(cls): 17 | if not cls._tfidf_extractor: 18 | cls._tfidf_extractor = TextblobTfIdfExtractStrategy() 19 | return cls._tfidf_extractor 20 | 21 | @classmethod 22 | def _extract_and_save(cls, article, disconnectAll=False): 23 | article_node = GraphArticle.nodes.get_or_none(uid=article.id) 24 | 25 | if not article_node: 26 | article_node = GraphArticle( 27 | uid=article.id, name=article.title).save() 28 | 29 | if disconnectAll: 30 | article_node.related.disconnect_all() 31 | 32 | keywords = cls._get_tfidf_extractor().extract_keyphrases(article.text) 33 | logger.info(f"Extracted keywords {keywords}") 34 | 35 | for keyword in keywords: 36 | 37 | related_title = keyword['word'] 38 | related_article = Article.objects.filter( 39 | title__iexact=related_title).first() 40 | 41 | if not related_article: 42 | related_article = Article(title=related_title, workspace=article.workspace) 43 | related_article.save() 44 | 45 | related_article_node = GraphArticle.nodes.get_or_none( 46 | uid=related_article.id) 47 | 48 | if not related_article_node: 49 | related_article_node = GraphArticle( 50 | uid=related_article.id, name=related_article.title) 51 | 52 | article_node.save() 53 | related_article_node.save() 54 | logger.info( 55 | f"Set {article_node.name} as related to {related_article_node.name}") 56 | 57 | if article_node.related.is_connected(related_article_node): 58 | rel = article_node.related.relationship(related_article_node) 59 | rel.tf_idf = keyword['tf-idf'] 60 | else: 61 | article_node.related.connect( 62 | related_article_node, {'tf_idf': keyword['tf-idf']}) 63 | 64 | @classmethod 65 | def generate_graph(cls): 66 | print("[ConceptRunner] generating graph for all articles") 67 | articles = Article.objects.all() 68 | 69 | for article in articles: 70 | cls._extract_and_save(article) 71 | 72 | @classmethod 73 | def generate_concepts_for_article(cls, articleId): 74 | article = Article.objects.get(pk=articleId) 75 | 76 | if article: 77 | cls._extract_and_save(article, disconnectAll=False) 78 | -------------------------------------------------------------------------------- /backend/grafit/crawler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/crawler/__init__.py -------------------------------------------------------------------------------- /backend/grafit/crawler/crawler.py: -------------------------------------------------------------------------------- 1 | from ..crawler.source import Source 2 | import logging 3 | import re 4 | import html2text 5 | 6 | logger = logging.getLogger() 7 | logger.setLevel(logging.INFO) 8 | 9 | 10 | class Crawler: 11 | 12 | def __init__(self): 13 | self.text_maker = html2text.HTML2Text() 14 | self.text_maker.ignore_links = True 15 | self.text_maker.skip_internal_links = True 16 | self.text_maker.bypass_tables = True 17 | self.text_maker.ignore_anchors = True 18 | self.text_maker.ignore_emphasis = True 19 | self.text_maker.ignore_tables = True 20 | self.text_maker.single_line_break = True 21 | self.text_maker.ignore_images = True 22 | 23 | def getWebContent(self, articleText: str) -> str: 24 | content = "" 25 | sources = Crawler.__extractURL(articleText) 26 | 27 | for source in sources: 28 | content += source.getContent(self.text_maker) 29 | return content 30 | 31 | @staticmethod 32 | def __extractURL(content: str) -> list: 33 | urlRegex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+' 34 | urls = re.findall(urlRegex, content) 35 | return list({Source(url) for url in urls}) 36 | -------------------------------------------------------------------------------- /backend/grafit/crawler/source.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import html2text 3 | import requests 4 | 5 | logger = logging.getLogger() 6 | logger.setLevel(logging.INFO) 7 | 8 | class Source: 9 | def __init__(self, url: str): 10 | self.url = url 11 | 12 | def getContent(self, HTML2Text) -> str: 13 | logger.info(f"Crawling: {self.url}") 14 | try: 15 | request = requests.get(self.url, timeout=5) 16 | request.raise_for_status() 17 | 18 | content_type = request.headers.get('content-type') 19 | if 'application/pdf' in content_type: 20 | logger.info(f"Failed to crawl: Content type {content_type}") 21 | return "" 22 | if 'application' in content_type: 23 | logger.info(f"Failed to crawl: Content type {content_type}") 24 | return "" 25 | 26 | return HTML2Text.handle(request.text) 27 | except Exception: 28 | logger.info(f"Failed to crawl: {self.url}") 29 | 30 | return "" 31 | -------------------------------------------------------------------------------- /backend/grafit/crawler/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/crawler/tests/__init__.py -------------------------------------------------------------------------------- /backend/grafit/crawler/tests/test_crawler.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from grafit.crawler.crawler import Crawler 3 | 4 | 5 | class CrawlerTest(TestCase): 6 | def test_crawler_no_content(self): 7 | crawler = Crawler() 8 | webContent = crawler.getWebContent("Nothing with any URL") 9 | self.assertEqual(webContent, "") 10 | 11 | def test_crawler_not_reachable_url(self): 12 | crawler = Crawler() 13 | webContent = crawler.getWebContent("Not existing URL https://www.dauiwrbn239hraoiwezfh.com/alsdäflqwäekf and some more text") 14 | self.assertEqual(webContent, "") 15 | 16 | def test_extractUrl_none(self): 17 | text = "" 18 | sourceList = Crawler._Crawler__extractURL(text) 19 | self.assertFalse(sourceList) 20 | 21 | def test_extractUrl_signle(self): 22 | text = "https://grafit.io/" 23 | sourceList = Crawler._Crawler__extractURL(text) 24 | 25 | self.assertEqual(sourceList[0].url, "https://grafit.io/") 26 | 27 | def test_extractUrl_multiple(self): 28 | text = "Some content is https://grafit.io/ and http://www.hslu.ch" 29 | sourceList = Crawler._Crawler__extractURL(text) 30 | 31 | self.assertEqual(len(sourceList), 2) -------------------------------------------------------------------------------- /backend/grafit/management/commands/clear_neo4j.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from neomodel import db, clear_neo4j_database 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Clear the neo4j database' 8 | 9 | def handle(self, *args, **options): 10 | self.stdout.write('Deleting all nodes..\n') 11 | clear_neo4j_database(db) 12 | self.stdout.write('Done.\n') 13 | -------------------------------------------------------------------------------- /backend/grafit/management/commands/install_labels.py: -------------------------------------------------------------------------------- 1 | from django import setup as setup_django 2 | from django.core.management.base import BaseCommand 3 | 4 | from neomodel import install_all_labels 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Install labels and constraints for your neo4j database' 9 | 10 | def handle(self, *args, **options): 11 | setup_django() 12 | install_all_labels(stdout=self.stdout) 13 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-25 09:36 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('auth', '0009_alter_user_last_name_max_length'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='User', 21 | fields=[ 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 33 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 34 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 35 | ], 36 | options={ 37 | 'verbose_name': 'user', 38 | 'verbose_name_plural': 'users', 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0002_article.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-27 09:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Article', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('title', models.CharField(max_length=250)), 18 | ('text', models.TextField()), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0004_article_related.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-29 12:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0003_load_data'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='article', 15 | name='related', 16 | field=models.ManyToManyField(related_name='_article_related_+', to='grafit.Article'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0005_auto_20181029_1255.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-29 12:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0004_article_related'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='article', 15 | name='related', 16 | field=models.ManyToManyField(blank=True, related_name='_article_related_+', to='grafit.Article'), 17 | ), 18 | migrations.AlterField( 19 | model_name='article', 20 | name='text', 21 | field=models.TextField(blank=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0006_fix_ids.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-25 09:36 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('grafit', '0005_auto_20181029_1255'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RunSQL(""" 18 | BEGIN; 19 | SELECT setval(pg_get_serial_sequence('"grafit_article_related"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_article_related"; 20 | SELECT setval(pg_get_serial_sequence('"grafit_article"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_article"; 21 | COMMIT; 22 | """), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0007_auto_20181109_1633.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-09 16:33 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('grafit', '0006_fix_ids'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='article', 16 | name='created_at', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='article', 22 | name='updated_at', 23 | field=models.DateTimeField(auto_now=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0008_workspace.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-12 14:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('grafit', '0007_auto_20181109_1633'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Workspace', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=250)), 19 | ('initials', models.CharField(max_length=2)), 20 | ('users', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0009_article_workspace.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-14 13:13 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('grafit', '0008_workspace'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RunSQL(""" 15 | INSERT INTO grafit_workspace (id, name, initials) 16 | VALUES (1, 'Software Development', 'SW') 17 | ON CONFLICT DO NOTHING 18 | """), 19 | migrations.AddField( 20 | model_name='article', 21 | name='workspace', 22 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='grafit.Workspace'), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0010_fix_ids2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-25 09:36 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('grafit', '0009_article_workspace'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RunSQL(""" 18 | BEGIN; 19 | SELECT setval(pg_get_serial_sequence('"grafit_user_groups"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_user_groups"; 20 | SELECT setval(pg_get_serial_sequence('"grafit_user_user_permissions"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_user_user_permissions"; 21 | SELECT setval(pg_get_serial_sequence('"grafit_workspace_users"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_workspace_users"; 22 | SELECT setval(pg_get_serial_sequence('"grafit_workspace"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_workspace"; 23 | SELECT setval(pg_get_serial_sequence('"grafit_article_related"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_article_related"; 24 | SELECT setval(pg_get_serial_sequence('"grafit_article"','id'), coalesce(max("id"), 1), max("id") IS NOT null) FROM "grafit_article"; 25 | COMMIT; 26 | """), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0011_remove_article_related.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-19 14:10 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0010_fix_ids2'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='article', 15 | name='related', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0012_create_search_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-21 18:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0011_remove_article_related'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | """ 15 | DROP MATERIALIZED VIEW IF EXISTS grafit_search_index; 16 | 17 | CREATE MATERIALIZED VIEW grafit_search_index AS 18 | SELECT 19 | node.id, 20 | node.title, 21 | setweight(to_tsvector('english', node.title), 'A') || 22 | setweight(to_tsvector('english', node.text), 'B') as document 23 | FROM grafit_article AS node 24 | GROUP BY node.id; 25 | 26 | DROP INDEX IF EXISTS idx_grafit_search_index; 27 | 28 | CREATE INDEX idx_grafit_search_index 29 | ON grafit_search_index 30 | USING gin (document); 31 | """ 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0013_add_unique_to_searchindex.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-22 14:17 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0012_create_search_index'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | """ 15 | DROP INDEX IF EXISTS grafit_search_unique; 16 | 17 | CREATE UNIQUE INDEX grafit_search_unique 18 | ON grafit_search_index (id); 19 | """ 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0014_create_search_word_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-12-05 17:10 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0013_add_unique_to_searchindex'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | """ 15 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 16 | DROP MATERIALIZED VIEW IF EXISTS grafit_search_word; 17 | 18 | CREATE MATERIALIZED VIEW grafit_search_word AS 19 | SELECT * 20 | FROM ts_stat( 21 | 'SELECT to_tsvector(''english'', node.title) || 22 | to_tsvector(''english'',coalesce(node.text, '''')) 23 | FROM grafit_article as node' 24 | ); 25 | 26 | CREATE INDEX words_idx ON grafit_search_word USING gin(word gin_trgm_ops); 27 | 28 | DROP INDEX IF EXISTS grafit_search_word_unique; 29 | CREATE UNIQUE INDEX grafit_search_word_unique ON grafit_search_word (word); 30 | """ 31 | ) 32 | ] 33 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0015_searchresult_searchword.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-12-06 13:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0014_create_search_word_index'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='SearchResult', 15 | fields=[ 16 | ('id', models.BigIntegerField(primary_key=True, serialize=False)), 17 | ('title', models.TextField()), 18 | ('headline', models.TextField()), 19 | ('rank', models.DecimalField(decimal_places=2, max_digits=19)), 20 | ], 21 | options={ 22 | 'db_table': 'search_index', 23 | 'managed': False, 24 | }, 25 | ), 26 | migrations.CreateModel( 27 | name='SearchWord', 28 | fields=[ 29 | ('word', models.TextField(primary_key=True, serialize=False)), 30 | ('similarity', models.DecimalField(decimal_places=2, max_digits=19)), 31 | ], 32 | options={ 33 | 'db_table': 'search_word', 34 | 'managed': False, 35 | }, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0016_add_webcontent_to_article.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-07 14:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0015_searchresult_searchword'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='article', 15 | name='update_web_content', 16 | field=models.BooleanField(default=True), 17 | ), 18 | migrations.AddField( 19 | model_name='article', 20 | name='web_content', 21 | field=models.TextField(default=None, null=True), 22 | ), 23 | migrations.RunSQL( 24 | """ 25 | DROP MATERIALIZED VIEW grafit_search_index; 26 | 27 | CREATE MATERIALIZED VIEW grafit_search_index AS 28 | SELECT 29 | node.id, 30 | node.title, 31 | setweight(to_tsvector('english', node.title), 'A') || 32 | setweight(to_tsvector('english', node.text), 'B') || 33 | setweight(to_tsvector('english', node.web_content), 'C') as document 34 | FROM grafit_article AS node 35 | GROUP BY node.id; 36 | 37 | DROP INDEX IF EXISTS idx_grafit_search_index; 38 | 39 | CREATE INDEX idx_grafit_search_index 40 | ON grafit_search_index 41 | USING gin (document); 42 | 43 | DROP INDEX IF EXISTS grafit_search_unique; 44 | 45 | CREATE UNIQUE INDEX grafit_search_unique 46 | ON grafit_search_index (id); 47 | """ 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /backend/grafit/migrations/0017_fix_null_search_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-09 14:54 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('grafit', '0016_add_webcontent_to_article'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | """ 15 | DROP MATERIALIZED VIEW grafit_search_index; 16 | 17 | CREATE MATERIALIZED VIEW grafit_search_index AS 18 | SELECT 19 | node.id, 20 | node.title, 21 | setweight(to_tsvector('english', node.title), 'A') || 22 | setweight(to_tsvector('english', node.text), 'B') || 23 | setweight(to_tsvector('english', COALESCE(node.web_content, '')), 'C') as document 24 | FROM grafit_article AS node 25 | GROUP BY node.id; 26 | 27 | DROP INDEX IF EXISTS idx_grafit_search_index; 28 | 29 | CREATE INDEX idx_grafit_search_index 30 | ON grafit_search_index 31 | USING gin (document); 32 | 33 | DROP INDEX IF EXISTS grafit_search_unique; 34 | 35 | CREATE UNIQUE INDEX grafit_search_unique 36 | ON grafit_search_index (id); 37 | """ 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /backend/grafit/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/migrations/__init__.py -------------------------------------------------------------------------------- /backend/grafit/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from textblob import TextBlob 5 | 6 | from django.contrib.auth.models import AbstractUser 7 | from django.db import models 8 | from neomodel import BooleanProperty, StructuredNode, StringProperty, UniqueIdProperty, Relationship, StructuredRel, FloatProperty, DateTimeProperty 9 | from django.db.models import signals 10 | from django.dispatch import receiver 11 | from django.db import connection 12 | from .crawler.crawler import Crawler 13 | 14 | import logging 15 | 16 | 17 | logger = logging.getLogger() 18 | logger.setLevel(logging.INFO) 19 | logger.addHandler(logging.StreamHandler()) 20 | 21 | 22 | class User(AbstractUser): 23 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 24 | 25 | def __str__(self): 26 | return self.username 27 | 28 | 29 | class Workspace(models.Model): 30 | name = models.CharField(max_length=250) 31 | initials = models.CharField(max_length=2) 32 | users = models.ManyToManyField(User) 33 | 34 | 35 | class Article(models.Model): 36 | 37 | def __init__(self, *args, **kwargs): 38 | super(Article, self).__init__(*args, **kwargs) 39 | self.update_web_content = True 40 | 41 | title = models.CharField(max_length=250) 42 | text = models.TextField(blank=True) 43 | workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE) 44 | created_at = models.DateTimeField(auto_now_add=True) 45 | updated_at = models.DateTimeField(auto_now=True) 46 | web_content = models.TextField(null=True, default=None) 47 | update_web_content = models.BooleanField(default=True) 48 | 49 | @property 50 | def related(self): 51 | relatedNodes = [] 52 | try: 53 | ownNode = GraphArticle.nodes.get(uid=self.id) 54 | relatedGraphNodes = ownNode.related 55 | for node in relatedGraphNodes: 56 | rel = ownNode.related.relationship(node) 57 | if not rel.hidden: 58 | relatedNodes.append({ 59 | "id": int(node.uid), 60 | "title": node.name, 61 | "label": rel.label 62 | }) 63 | except: 64 | # article node does not exist yet 65 | pass 66 | 67 | return relatedNodes 68 | 69 | @property 70 | def shorttext(self): 71 | blob = TextBlob(self.text) 72 | firstSentences = " ".join([str(s) for s in blob.sentences[:3]]) 73 | if len(firstSentences) > 400: 74 | return self.text[:400].rsplit(' ', 1)[0] + " [...]" 75 | else: 76 | return firstSentences 77 | 78 | def __unicode__(self): 79 | return '{"title": %s, "title" %s}' % (self.id, self.title) 80 | 81 | 82 | @receiver([signals.post_save], sender=Article, dispatch_uid="crawl_url_in_article") 83 | def crawl_urls_in_article(sender, instance, created, **kwargs): 84 | if instance.update_web_content: 85 | crawler = Crawler() 86 | content = crawler.getWebContent(instance.text) 87 | Article.objects.filter(id=instance.id).update(web_content=content, update_web_content=False) 88 | 89 | 90 | @receiver([signals.post_save, signals.post_delete], sender=Article, dispatch_uid="update_search_index") 91 | def update_search_index(sender, instance, **kwargs): 92 | logger.info("update search index") 93 | cursor = connection.cursor() 94 | cursor.execute( 95 | 'REFRESH MATERIALIZED VIEW CONCURRENTLY grafit_search_index;') 96 | logger.info("finished updating search index") 97 | 98 | 99 | @receiver([signals.post_save, signals.post_delete], sender=Article, dispatch_uid="update_search_word") 100 | def update_search_word(sender, instance, **kwargs): 101 | logger.info("update search word") 102 | cursor = connection.cursor() 103 | cursor.execute( 104 | 'REFRESH MATERIALIZED VIEW CONCURRENTLY grafit_search_word;') 105 | logger.info("finished updating search word") 106 | 107 | 108 | class ArticleRel(StructuredRel): 109 | created_at = DateTimeProperty( 110 | default=lambda: datetime.now() 111 | ) 112 | tf_idf = FloatProperty() 113 | hidden = BooleanProperty(default=False) 114 | label = StringProperty() 115 | 116 | 117 | class GraphArticle(StructuredNode): 118 | uid = UniqueIdProperty() 119 | name = StringProperty() 120 | related = Relationship('GraphArticle', 'RELATED', model=ArticleRel) 121 | 122 | 123 | class SearchResult(models.Model): 124 | id = models.BigIntegerField(primary_key=True) 125 | title = models.TextField() 126 | headline = models.TextField() 127 | rank = models.DecimalField(max_digits=19, decimal_places=2) 128 | 129 | class Meta: 130 | managed = False 131 | db_table = 'search_index' 132 | 133 | 134 | class SearchWord(models.Model): 135 | word = models.TextField(primary_key=True) 136 | similarity = models.DecimalField(max_digits=19, decimal_places=2) 137 | 138 | class Meta: 139 | managed = False 140 | db_table = 'search_word' 141 | -------------------------------------------------------------------------------- /backend/grafit/search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/search/__init__.py -------------------------------------------------------------------------------- /backend/grafit/search/search.py: -------------------------------------------------------------------------------- 1 | from ..models import SearchResult, SearchWord 2 | import logging 3 | 4 | logger = logging.getLogger() 5 | logger.setLevel(logging.INFO) 6 | 7 | 8 | class Search: 9 | 10 | @staticmethod 11 | def __cleanSearchTerm(searchTerm: str) -> str: 12 | 13 | searchTerm = searchTerm.strip() 14 | 15 | # Remove critical character for ts_query 16 | searchTerm = searchTerm.replace("&", " ") 17 | searchTerm = searchTerm.replace("!", " ") 18 | searchTerm = searchTerm.replace("|", " ") 19 | 20 | # Remove multiple withspace 21 | searchTerm = ' '.join(searchTerm.split()) 22 | 23 | # Add OR between all words 24 | searchTerm = searchTerm.replace(" ", "|") 25 | 26 | return searchTerm 27 | 28 | @staticmethod 29 | def __runSearchQuery(searchTerm: str): 30 | return SearchResult.objects.raw(''' 31 | SELECT grafit_search_index.id, ts_headline(grafit_search_index.title, to_tsquery('english', %(query)s)) as title, ts_headline(grafit_article.text, to_tsquery('english', %(query)s)) as headline, ts_rank(document, to_tsquery('english', %(query)s)) as rank 32 | FROM grafit_search_index 33 | INNER JOIN grafit_article ON grafit_article.id = grafit_search_index.id 34 | WHERE grafit_search_index.document @@ to_tsquery('english', %(query)s) 35 | ORDER BY rank DESC 36 | LIMIT 25''', {'query': searchTerm}) 37 | 38 | @staticmethod 39 | def __findSimilarWord(word: str) -> str: 40 | 41 | queryset = SearchWord.objects.raw(''' 42 | SELECT word, similarity(word, %(word)s) as similarity 43 | FROM grafit_search_word 44 | WHERE similarity(word, %(word)s) > 0.5 45 | ORDER BY similarity desc 46 | LIMIT 1; 47 | ''', {'word': word}) 48 | 49 | if len(queryset) > 0: 50 | for item in queryset: 51 | return item.word 52 | return None 53 | 54 | @staticmethod 55 | def search(searchTerm: str): 56 | cleanSearchTerm = Search.__cleanSearchTerm(searchTerm) 57 | logger.info("searching for: " + cleanSearchTerm) 58 | 59 | queryset = Search.__runSearchQuery(cleanSearchTerm) 60 | resultsCount = len(queryset) 61 | 62 | if(resultsCount < 1): 63 | searchWords = searchTerm.split("|") 64 | for word in searchWords: 65 | similarWord = Search.__findSimilarWord(word) 66 | if similarWord is not None: 67 | cleanSearchTerm += '|' 68 | cleanSearchTerm += similarWord 69 | queryset = Search.__runSearchQuery(cleanSearchTerm) 70 | 71 | return queryset -------------------------------------------------------------------------------- /backend/grafit/search/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/search/tests/__init__.py -------------------------------------------------------------------------------- /backend/grafit/search/tests/test_search.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.test import force_authenticate, APITestCase, APIClient 3 | from rest_framework import status 4 | from django.urls import reverse 5 | from grafit.search.search import Search 6 | from grafit.models import Article, Workspace, User 7 | 8 | 9 | class SearchTest(TestCase): 10 | def test_cleanSearchTerm_spaceToOr(self): 11 | testTerm = "word1 word2" 12 | expectedResult = "word1|word2" 13 | assert Search._Search__cleanSearchTerm(testTerm) == expectedResult 14 | 15 | def test_cleanSearchTerm_removeAnd(self): 16 | testTerm = "word1&word2" 17 | expectedResult = "word1|word2" 18 | assert Search._Search__cleanSearchTerm(testTerm) == expectedResult 19 | 20 | def test_cleanSearchTerm_removeExcamation(self): 21 | testTerm = "word1&word2" 22 | expectedResult = "word1|word2" 23 | assert Search._Search__cleanSearchTerm(testTerm) == expectedResult 24 | 25 | def test_cleanSearchTerm_removeMultispace(self): 26 | testTerm = " word1 word2 " 27 | expectedResult = "word1|word2" 28 | assert Search._Search__cleanSearchTerm(testTerm) == expectedResult 29 | 30 | 31 | class SearchAPITest(APITestCase): 32 | def setUp(self): 33 | test_user = User.objects.create_user( 34 | 'testuser2', 'test@example.com', 'testpassword') 35 | self.workspace = Workspace(name="Testworkspace2", initials="TE") 36 | self.workspace.save() 37 | self.workspace.users.add(test_user) 38 | 39 | for i in range(0, 50): 40 | Article.objects.create( 41 | title="TestTitle", text="TestText", workspace=self.workspace) 42 | 43 | self.client = APIClient() 44 | self.user = User.objects.get(username='testuser2') 45 | self.client.force_authenticate(user=self.user) 46 | 47 | def test_search_limit(self): 48 | response = self.client.get( 49 | reverse('search-list'), {'searchTerm': 'TestTitle'}, 50 | format="json") 51 | 52 | self.assertEqual(response.status_code, status.HTTP_200_OK) 53 | self.assertEqual(len(response.data), min( 54 | Article.objects.filter(workspace__users=self.user).count(), 25)) 55 | 56 | def test_search_fuzzy(self): 57 | testArticle = Article.objects.create(title="Open Source Document Database MongoDB", 58 | text="We're the creators of MongoDB, the most popular database for modern apps, and MongoDB Atlas, the global cloud database on AWS, Azure, and GCP. Easily organize, use, and enrich data — in real time, anywhere.", workspace=self.workspace) 59 | 60 | response = self.client.get( 61 | reverse('search-list'), {'searchTerm': 'MongoD'}, 62 | format="json") 63 | 64 | results = [] 65 | 66 | for respnseObject in response.data: 67 | results.append(respnseObject['id']) 68 | 69 | self.assertEqual(response.status_code, status.HTTP_200_OK) 70 | self.assertIn(testArticle.id, results) 71 | -------------------------------------------------------------------------------- /backend/grafit/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from rest_framework import serializers 3 | from rest_framework.validators import UniqueTogetherValidator 4 | from .concept_runner import ConceptRunner 5 | 6 | from .models import User, Article, Workspace, GraphArticle, SearchResult, SearchWord 7 | 8 | 9 | class UserSerializer(serializers.HyperlinkedModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ('url', 'username', 'email', 13 | 'groups', 'first_name', 'last_name') 14 | 15 | 16 | class CreateUserSerializer(serializers.ModelSerializer): 17 | def create(self, validated_data): 18 | # call create_user on user object. Without this 19 | # the password will be stored in plain text. 20 | user = User.objects.create_user(**validated_data) 21 | return user 22 | 23 | class Meta: 24 | model = User 25 | fields = ('url', 'username', 'password', 26 | 'first_name', 'last_name', 'email',) 27 | extra_kwargs = {'password': {'write_only': True}} 28 | 29 | 30 | class GroupSerializer(serializers.HyperlinkedModelSerializer): 31 | class Meta: 32 | model = Group 33 | fields = ('url', 'name') 34 | 35 | 36 | class WorkspaceSerializer(serializers.ModelSerializer): 37 | class Meta: 38 | model = Workspace 39 | fields = ('id', 'url', 'name', 'initials') 40 | 41 | 42 | class SearchResultSerializer(serializers.ModelSerializer): 43 | class Meta: 44 | model = SearchResult 45 | fields = ('id', 'title', 'headline', 'rank') 46 | 47 | 48 | class SearchWordSerializer(serializers.ModelSerializer): 49 | class Meta: 50 | model = SearchWord 51 | fields = ('word', 'similarity') 52 | 53 | 54 | class ArticleTitleSerializer(serializers.ModelSerializer): 55 | class Meta: 56 | model = Article 57 | fields = ('id', 'url', 'title', 'workspace') 58 | 59 | 60 | class ArticleSerializer(serializers.ModelSerializer): 61 | class Meta: 62 | model = Article 63 | fields = ('id', 'url', 'title', 'text', 'shorttext', 64 | 'related', 'workspace', 'created_at', 'updated_at') 65 | 66 | validators = [ 67 | UniqueTogetherValidator( 68 | queryset=Article.objects.all(), 69 | fields=('title', 'workspace') 70 | ) 71 | ] 72 | 73 | def _save_related(self, article): 74 | ConceptRunner.generate_concepts_for_article(article.id) 75 | 76 | def create(self, validated_data): 77 | article = Article.objects.create(**validated_data) 78 | self._save_related(article) 79 | return article 80 | 81 | def update(self, instance, validated_data): 82 | instance.update_web_content = True 83 | super(ArticleSerializer, self).update(instance, validated_data) 84 | self._save_related(instance) 85 | return instance 86 | -------------------------------------------------------------------------------- /backend/grafit/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/backend/grafit/tests/__init__.py -------------------------------------------------------------------------------- /backend/grafit/tests/test_articles.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import force_authenticate, APITestCase, APIClient 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | from ..models import Article, Workspace, User 6 | 7 | 8 | class ArticleTest(APITestCase): 9 | def setUp(self): 10 | test_user = User.objects.create_user('testuser', 'test@example.com', 'testpassword') 11 | workspace = Workspace(name="Testworkspace", initials="TE") 12 | workspace.save() 13 | workspace.users.add(test_user) 14 | 15 | Article.objects.create(title="TestTitle", text="TestText", workspace=workspace) 16 | 17 | self.client = APIClient() 18 | self.user = User.objects.get(username='testuser') 19 | self.client.force_authenticate(user=self.user) 20 | self.workspace = workspace 21 | 22 | def test_article_list(self): 23 | raw_response = self.client.get( 24 | reverse('article-list'), 25 | format="json") 26 | response = raw_response.data['results'] 27 | 28 | self.assertEqual(raw_response.status_code, status.HTTP_200_OK) 29 | self.assertEqual(len(response), Article.objects.filter(workspace__users=self.user).count()) 30 | self.assertEqual(response[0]['title'], "TestTitle") 31 | self.assertEqual(response[0]['text'], "TestText") 32 | 33 | def test_article_list_pagination(self): 34 | 35 | for i in range(0, 200): 36 | Article.objects.create(title="TestTitle", text="TestText", workspace=self.workspace) 37 | 38 | raw_response = self.client.get( 39 | reverse('article-list'), 40 | format="json") 41 | response = raw_response.data['results'] 42 | 43 | self.assertEqual(raw_response.status_code, status.HTTP_200_OK) 44 | self.assertEqual(len(response), min(Article.objects.filter(workspace__users=self.user).count(), 25)) 45 | self.assertEqual(response[0]['title'], "TestTitle") 46 | self.assertEqual(response[0]['text'], "TestText") 47 | 48 | def test_article_list_workspace_permission(self): 49 | other_user = User.objects.create_user('test2', 'test2@example.com', 'testpassword') 50 | other_workspace = Workspace(name="Testworkspace", initials="TE") 51 | other_workspace.save() 52 | other_workspace.users.add(other_user) 53 | 54 | Article.objects.create(title="TestTitle2", text="TestText2", workspace=other_workspace) 55 | 56 | raw_response = self.client.get( 57 | reverse('article-list'), 58 | format="json") 59 | response = raw_response.data['results'] 60 | 61 | self.assertEqual(raw_response.status_code, status.HTTP_200_OK) 62 | self.assertEqual(len(response), Article.objects.filter(workspace__users=self.user).count()) 63 | 64 | def test_article_create(self): 65 | url = reverse('article-list') 66 | data = { 67 | 'title': 'New Article Title', 68 | 'text': 'Test test test test', 69 | 'workspace': Workspace.objects.get(name="Testworkspace").id 70 | } 71 | response = self.client.post(url, data, format='json') 72 | 73 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 74 | self.assertEqual(Article.objects.get(title='New Article Title').title, 'New Article Title') 75 | self.assertEqual(Article.objects.get(title='New Article Title').text, 'Test test test test') 76 | 77 | def test_article_create_notext(self): 78 | numberOfArticles = Article.objects.all().count() 79 | 80 | url = reverse('article-list') 81 | data = { 82 | 'title': 'New Article Title', 83 | 'workspace': Workspace.objects.get(name="Testworkspace").id 84 | } 85 | response = self.client.post(url, data, format='json') 86 | 87 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 88 | self.assertEqual(Article.objects.count(), numberOfArticles + 1) 89 | self.assertEqual(Article.objects.get(title="New Article Title").title, 'New Article Title') 90 | 91 | def test_update_article(self): 92 | numberOfArticles = Article.objects.all().count() 93 | testArticleId = Article.objects.get(title="TestTitle").id 94 | 95 | data = { 96 | 'title': 'Test123', 97 | 'text': '', 98 | 'workspace': Workspace.objects.get(name="Testworkspace").id 99 | } 100 | response = self.client.put(reverse('article-detail', args=[testArticleId]), data, format="json") 101 | 102 | self.assertEqual(response.status_code, status.HTTP_200_OK) 103 | self.assertEqual(Article.objects.count(), numberOfArticles) 104 | self.assertEqual(Article.objects.get(pk=testArticleId).title, 'Test123') 105 | self.assertEqual(Article.objects.get(pk=testArticleId).text, '') 106 | 107 | def test_article_delete(self): 108 | numberOfArticles = Article.objects.all().count() 109 | articleId = Article.objects.get(title="TestTitle").id 110 | response = self.client.delete(reverse('article-detail', args=[articleId])) 111 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 112 | self.assertEqual(Article.objects.all().count(), numberOfArticles - 1) 113 | -------------------------------------------------------------------------------- /backend/grafit/tests/test_workspaces.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import force_authenticate, APITestCase, APIClient 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | from ..models import Workspace, User 6 | 7 | 8 | class WorkspaceTest(APITestCase): 9 | def setUp(self): 10 | testWorkspace = Workspace(name="Testworkspace", 11 | initials="TE") 12 | testWorkspace.save() 13 | 14 | User.objects.create_user( 15 | 'testuser', 'test@example.com', 'testpassword') 16 | 17 | testWorkspace.users.add(User.objects.get(username='testuser')) 18 | Workspace.objects.create(name="fail", 19 | initials="FA") 20 | 21 | self.client = APIClient() 22 | self.user = User.objects.get(username='testuser') 23 | self.client.force_authenticate(user=self.user) 24 | 25 | def test_workspace_get(self): 26 | """ 27 | Create two workspaces. Add one to the testuser. Check if only one of them is returned. 28 | """ 29 | 30 | response = self.client.get( 31 | reverse('workspace-list'), 32 | format="json") 33 | 34 | self.assertEqual(len(response.data), 1) 35 | self.assertEqual(response.data[0]['initials'], 'TE') 36 | 37 | def test_workspace_create(self): 38 | 39 | numberOfWorkspaces = Workspace.objects.all().count() 40 | 41 | data = { 42 | "name": "Test Workspace", 43 | "initials": "TW" 44 | } 45 | 46 | response = self.client.post( 47 | reverse('workspace-list'), data, format="json") 48 | 49 | self.assertEqual(Workspace.objects.count(), numberOfWorkspaces + 1) 50 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 51 | 52 | def test_workspace_create_noinitials(self): 53 | data = { 54 | "name": "Test Workspace", 55 | "initials": "" 56 | } 57 | 58 | response = self.client.post( 59 | reverse('workspace-list'), data, format="json") 60 | 61 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 62 | 63 | def test_workspace_create_notitle(self): 64 | data = { 65 | "name": "", 66 | "initials": "TW" 67 | } 68 | 69 | response = self.client.post( 70 | reverse('workspace-list'), data, format="json") 71 | 72 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 73 | 74 | def test_workspace_create_autoasign(self): 75 | 76 | originalWorkspaceCount = len(self.client.get( 77 | reverse('workspace-list'), 78 | format="json").data) 79 | 80 | data = { 81 | "name": "Test Workspace", 82 | "initials": "TW" 83 | } 84 | 85 | response = self.client.post( 86 | reverse('workspace-list'), data, format="json") 87 | 88 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 89 | 90 | newWorkspaceCount = len(self.client.get( 91 | reverse('workspace-list'), 92 | format="json").data) 93 | 94 | self.assertEqual(newWorkspaceCount, originalWorkspaceCount + 1) 95 | -------------------------------------------------------------------------------- /backend/grafit/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.contrib.auth.models import Group 3 | from .serializers import UserSerializer, GroupSerializer, ArticleTitleSerializer, CreateUserSerializer, ArticleSerializer, WorkspaceSerializer, SearchResultSerializer 4 | from rest_framework import viewsets, mixins, status 5 | from rest_framework.permissions import AllowAny 6 | from rest_framework.pagination import LimitOffsetPagination 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | from .concept_runner import ConceptRunner 10 | from .search.search import Search 11 | from .models import User, Article, Workspace, GraphArticle 12 | import logging 13 | 14 | 15 | logger = logging.getLogger() 16 | logger.setLevel(logging.INFO) 17 | logger.addHandler(logging.StreamHandler()) 18 | 19 | 20 | class UserViewSet(mixins.RetrieveModelMixin, 21 | mixins.UpdateModelMixin, 22 | viewsets.GenericViewSet): 23 | """ 24 | API endpoint that allows users to be viewed or edited. 25 | """ 26 | queryset = User.objects.all().order_by('-date_joined') 27 | serializer_class = UserSerializer 28 | 29 | 30 | class UserCreateViewSet(mixins.CreateModelMixin, 31 | viewsets.GenericViewSet): 32 | """ 33 | Creates user accounts 34 | """ 35 | queryset = User.objects.all() 36 | serializer_class = CreateUserSerializer 37 | permission_classes = (AllowAny,) 38 | 39 | 40 | class GroupViewSet(viewsets.ModelViewSet): 41 | """ 42 | API endpoint that allows groups to be viewed or edited. 43 | """ 44 | queryset = Group.objects.all() 45 | serializer_class = GroupSerializer 46 | 47 | 48 | class ArticleTitleViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): 49 | serializer_class = ArticleTitleSerializer 50 | 51 | def get_queryset(self): 52 | user = self.request.user 53 | return Article.objects.filter(workspace__users=user) 54 | 55 | 56 | class ArticleViewSet(viewsets.ModelViewSet): 57 | serializer_class = ArticleSerializer 58 | pagination_class = LimitOffsetPagination 59 | 60 | def get_queryset(self): 61 | user = self.request.user 62 | return Article.objects.filter(workspace__users=user) 63 | 64 | def list(self, request): 65 | articles = self.filter_queryset(Article.objects.filter( 66 | workspace__users=request.user).exclude(text__exact="").order_by('-updated_at')) 67 | page = self.paginate_queryset(articles) 68 | serializer = self.get_serializer(page, many=True) 69 | return self.get_paginated_response(serializer.data) 70 | 71 | 72 | class WorkspaceViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, 73 | viewsets.GenericViewSet): 74 | serializer_class = WorkspaceSerializer 75 | 76 | def perform_create(self, serializer): 77 | serializer.save(users=[self.request.user]) 78 | 79 | def get_queryset(self): 80 | """ 81 | This view should return a list of all the workspaces 82 | for the currently authenticated user. 83 | """ 84 | user = self.request.user 85 | return Workspace.objects.filter(users=user) 86 | 87 | 88 | class SearchResultViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 89 | queryset = [] 90 | 91 | def list(self, request): 92 | searchTerm = request.query_params.get('searchTerm', None) 93 | 94 | if not searchTerm: 95 | return Response([]) 96 | 97 | queryset = Search.search(searchTerm) 98 | 99 | serializer = SearchResultSerializer(queryset, many=True) 100 | return Response(serializer.data) 101 | 102 | 103 | class ConceptRunnerAPI(APIView): 104 | def get(self, request, format=None): 105 | articleId = request.query_params.get('id') 106 | try: 107 | if articleId: 108 | ConceptRunner.generate_concepts_for_article(int(articleId)) 109 | else: 110 | ConceptRunner.generate_graph() 111 | return Response(status.HTTP_200_OK) 112 | except: 113 | return Response(status.HTTP_500_INTERNAL_SERVER_ERROR) 114 | 115 | 116 | class AddConceptAPI(APIView): 117 | def post(self, request): 118 | if "from" in request.data and "to" in request.data: 119 | fromId = request.data["from"] 120 | toId = request.data["to"] 121 | 122 | label = "unlabeled" 123 | if "label" in request.data: 124 | label = request.data["label"] 125 | 126 | try: 127 | article_node_from = GraphArticle.nodes.get_or_none(uid=fromId) 128 | article_node_to = GraphArticle.nodes.get_or_none(uid=toId) 129 | 130 | if not article_node_from or not article_node_to: 131 | return Response(status.HTTP_404_NOT_FOUND) 132 | else: 133 | # TODO add by user flag to connection 134 | rel = article_node_from.related.relationship(article_node_to) 135 | if rel: 136 | rel.hidden = False 137 | rel.label = label 138 | rel.save() 139 | else: 140 | article_node_from.related.connect( 141 | article_node_to, {'tf_idf': 0, 'label': label}) 142 | return Response(status.HTTP_201_CREATED) 143 | 144 | except: 145 | return Response(status.HTTP_500_INTERNAL_SERVER_ERROR) 146 | 147 | else: 148 | return Response(status.HTTP_404_NOT_FOUND) 149 | 150 | 151 | class HideConceptAPI(APIView): 152 | def post(self, request): 153 | 154 | if "from" in request.data and "to" in request.data: 155 | fromId = request.data["from"] 156 | toId = request.data["to"] 157 | 158 | try: 159 | article_node_from = GraphArticle.nodes.get_or_none(uid=fromId) 160 | article_node_to = GraphArticle.nodes.get_or_none(uid=toId) 161 | 162 | if not article_node_from or not article_node_to: 163 | return Response(status.HTTP_404_NOT_FOUND) 164 | else: 165 | rel = article_node_from.related.relationship(article_node_to) 166 | rel.hidden = True 167 | rel.save() 168 | return Response(rel.hidden) 169 | 170 | except: 171 | return Response(status.HTTP_500_INTERNAL_SERVER_ERROR) 172 | 173 | else: 174 | return Response(status.HTTP_404_NOT_FOUND) 175 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /backend/wait_for_neo4j.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from time import time, sleep 4 | 5 | from neomodel import db 6 | 7 | DATABASE_URL = 'bolt://neo4j:test@neo4j:7687' 8 | 9 | check_timeout = os.getenv("NEO4J_CHECK_TIMEOUT", 60) 10 | check_interval = os.getenv("NEO4J_CHECK_INTERVAL", 1) 11 | interval_unit = "second" if check_interval == 1 else "seconds" 12 | 13 | start_time = time() 14 | logger = logging.getLogger() 15 | logger.setLevel(logging.INFO) 16 | logger.addHandler(logging.StreamHandler()) 17 | 18 | 19 | def n4j_isready(): 20 | while time() - start_time < check_timeout: 21 | try: 22 | db.set_connection(DATABASE_URL) 23 | logger.info("Neo4J is ready!") 24 | return True 25 | except: 26 | logger.info(f"Neo4j isn't ready. Waiting for {check_interval} {interval_unit}...") 27 | sleep(check_interval) 28 | 29 | logger.error(f"We could not connect to Neo4j within {check_timeout} seconds.") 30 | return False 31 | 32 | 33 | n4j_isready() 34 | -------------------------------------------------------------------------------- /backend/wait_for_postgres.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from time import time, sleep 4 | 5 | import psycopg2 6 | 7 | check_timeout = os.getenv("POSTGRES_CHECK_TIMEOUT", 30) 8 | check_interval = os.getenv("POSTGRES_CHECK_INTERVAL", 1) 9 | interval_unit = "second" if check_interval == 1 else "seconds" 10 | config = { 11 | "dbname": os.getenv("POSTGRES_DB", "postgres"), 12 | "user": os.getenv("POSTGRES_USER", "postgres"), 13 | "password": os.getenv("POSTGRES_PASSWORD", ""), 14 | "host": os.getenv("DATABASE_URL", "db") 15 | } 16 | 17 | start_time = time() 18 | logger = logging.getLogger() 19 | logger.setLevel(logging.INFO) 20 | logger.addHandler(logging.StreamHandler()) 21 | 22 | 23 | def pg_isready(host, user, password, dbname): 24 | while time() - start_time < check_timeout: 25 | try: 26 | conn = psycopg2.connect(**vars()) 27 | logger.info("Postgres is ready!") 28 | conn.close() 29 | return True 30 | except psycopg2.OperationalError: 31 | logger.info(f"Postgres isn't ready. Waiting for {check_interval} {interval_unit}...") 32 | sleep(check_interval) 33 | 34 | logger.error(f"We could not connect to Postgres within {check_timeout} seconds.") 35 | return False 36 | 37 | 38 | pg_isready(**config) 39 | -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: "2.3" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | 7 | neo4j: 8 | image: neo4j 9 | environment: 10 | NEO4J_AUTH: "neo4j/test" 11 | 12 | backend: 13 | image: grafit-backend 14 | build: 15 | context: ./backend 16 | target: prod 17 | ports: 18 | - 8000:8000 19 | restart: always 20 | volumes: 21 | - ./backend:/code 22 | depends_on: 23 | - db 24 | - neo4j 25 | 26 | frontend: 27 | image: grafit-frontend 28 | build: 29 | context: ./frontend 30 | target: prod 31 | ports: 32 | - 3000:80 33 | restart: always 34 | volumes: 35 | - ./frontend:/usr/src/grafit 36 | - /usr/src/grafit/node_modules 37 | depends_on: 38 | - backend 39 | 40 | documentation: 41 | image: grafit-docs 42 | build: ./mkdocs 43 | restart: always 44 | volumes: 45 | - ./mkdocs:/code 46 | ports: 47 | - "8001:8001" 48 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "2.3" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | 7 | neo4j: 8 | image: neo4j 9 | ports: 10 | - 7474:7474 11 | - 7687:7687 12 | environment: 13 | NEO4J_AUTH: "neo4j/test" 14 | 15 | backend: 16 | image: grafit-backend 17 | build: 18 | context: ./backend 19 | target: base 20 | ports: 21 | - 8000:8000 22 | restart: always 23 | volumes: 24 | - ./backend:/code 25 | depends_on: 26 | - db 27 | - neo4j 28 | 29 | frontend: 30 | image: grafit-frontend 31 | build: 32 | context: ./frontend 33 | target: base 34 | ports: 35 | - 3000:3000 36 | restart: always 37 | volumes: 38 | - ./frontend:/usr/src/grafit 39 | - /usr/src/grafit/node_modules 40 | depends_on: 41 | - backend 42 | 43 | documentation: 44 | restart: always 45 | build: ./mkdocs 46 | volumes: 47 | - ./mkdocs:/code 48 | ports: 49 | - "8001:8001" 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.3" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | 7 | neo4j: 8 | image: neo4j 9 | environment: 10 | NEO4J_AUTH: "neo4j/test" 11 | 12 | backend: 13 | image: grafitio/grafit-backend 14 | ports: 15 | - 8000:8000 16 | restart: always 17 | depends_on: 18 | - db 19 | - neo4j 20 | 21 | frontend: 22 | image: grafitio/grafit-frontend 23 | ports: 24 | - 3000:80 25 | restart: always 26 | depends_on: 27 | - backend 28 | 29 | documentation: 30 | image: grafitio/grafit-docs 31 | restart: always 32 | ports: 33 | - "8001:8001" 34 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # swap files 24 | .swp 25 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.12.0-alpine as base 2 | 3 | LABEL Name=grafit-frontend-dev Version=0.0.1 4 | EXPOSE 3000 5 | 6 | COPY . /usr/src/grafit 7 | WORKDIR /usr/src/grafit 8 | 9 | # If you are building your code for production 10 | # RUN npm install --only=production 11 | RUN npm install 12 | CMD [ "npm", "start" ] 13 | 14 | 15 | FROM base as build 16 | RUN npm run build 17 | 18 | 19 | FROM nginx:1.15.5-alpine as prod 20 | 21 | # remove default confs 22 | RUN rm /etc/nginx/conf.d/default.conf 23 | 24 | LABEL Name=grafit-frontend Version=0.0.1 25 | EXPOSE 80 26 | 27 | COPY --from=build /usr/src/grafit/build /usr/share/nginx/html 28 | COPY ./nginx.conf /etc/nginx/nginx.conf 29 | 30 | # test nginx config 31 | RUN nginx -t -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | #gzip on; 29 | 30 | server { 31 | listen 80; 32 | server_name localhost; 33 | 34 | location / { 35 | root /usr/share/nginx/html; 36 | try_files $uri $uri/ /index.html; 37 | } 38 | 39 | # redirect server error pages to the static page /50x.html 40 | error_page 500 502 503 504 /50x.html; 41 | location = /50x.html { 42 | root /usr/share/nginx/html; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "bootstrap": "^4.1.3", 7 | "node-sass": "^4.11.0", 8 | "react": "^16.5.2", 9 | "react-bootstrap": "^0.32.4", 10 | "react-dom": "^16.5.2", 11 | "react-router-dom": "^4.3.1", 12 | "react-scripts": "2.0.5", 13 | "react-tag-input": "^6.1.0", 14 | "react-treebeard": "^3.1.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 18 | 22 | 23 | 27 | 28 | 37 | grafit.io 38 | 39 | 40 | 41 | 42 |
43 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /frontend/public/logo_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/frontend/public/logo_192.png -------------------------------------------------------------------------------- /frontend/public/logo_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/frontend/public/logo_512.png -------------------------------------------------------------------------------- /frontend/public/logo_transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Untitled-1 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "grafit", 3 | "name": "grafit - share knowledge", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo_192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo_512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#524179", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .badge { 2 | margin-right: 5px; 3 | } 4 | 5 | .content > div { 6 | max-width: 1000px; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | BrowserRouter as Router, 4 | Route, 5 | Redirect, 6 | Switch 7 | } from "react-router-dom"; 8 | import ArticleList from "./components/article/ArticleList"; 9 | import ArticleDetail from "./components/article/articledetail/ArticleDetail"; 10 | import Login from "./components/login/Login"; 11 | import Register from "./components/login/Register"; 12 | import Nagivation from "./components/navigation/Navigation"; 13 | import Workspace from "./components/workspace/Workspace"; 14 | import WorkspaceToggle from "./components/workspace/WorkspaceToggle"; 15 | import CreateWorkspace from "./components/workspace/CreateWorkspace"; 16 | import Searchbar from "./components/search/Searchbar"; 17 | import AlertSnack from "./components/AlertSnack"; 18 | import { AuthService } from "./services/AuthService"; 19 | import "./App.css"; 20 | 21 | class App extends Component { 22 | state = { 23 | isAuthenticated: false, 24 | finishedLoading: false, 25 | currentWorkspace: undefined, 26 | refreshWorkspaces: false, 27 | alerts: [] 28 | }; 29 | 30 | createAlert = (title, message, type = "success", duration = 5000) => { 31 | this.setState(prevState => ({ 32 | alerts: [ 33 | ...prevState.alerts, 34 | { title: title, message: message, type: type, duration: duration } 35 | ] 36 | })); 37 | // auto remove after duration 38 | setTimeout(() => { 39 | this.setState(prevState => ({ 40 | alerts: prevState.alerts.filter( 41 | alert => alert.title !== title && alert.message !== message 42 | ) 43 | })); 44 | }, duration); 45 | }; 46 | 47 | handleWorkspaceChange = workspace => { 48 | this.setState({ currentWorkspace: workspace }); 49 | }; 50 | 51 | refreshWorkspaces = () => { 52 | this.setState({ refreshWorkspaces: !this.state.refreshWorkspaces }); 53 | }; 54 | 55 | userHasAuthenticated = authenticated => { 56 | this.setState({ isAuthenticated: authenticated }); 57 | }; 58 | 59 | componentWillMount() { 60 | AuthService.isLoggedIn().then(loggedIn => 61 | this.setState({ 62 | finishedLoading: true, 63 | isAuthenticated: loggedIn 64 | }) 65 | ); 66 | } 67 | 68 | render() { 69 | const authProps = { 70 | isAuthenticated: this.state.isAuthenticated, 71 | userHasAuthenticated: this.userHasAuthenticated 72 | }; 73 | 74 | return ( 75 | 76 |
77 | 78 | 79 | 85 | 86 |
87 | 88 | 89 | 90 | 91 | } /> 92 | 93 | ( 96 | 101 | )} 102 | /> 103 | {this.state.finishedLoading && !this.state.isAuthenticated && ( 104 | 105 | )} 106 | ( 110 | 115 | )} 116 | /> 117 | ( 121 | 126 | )} 127 | /> 128 | )} 129 | 130 |
131 |
132 |
133 | ); 134 | } 135 | } 136 | 137 | export default App; 138 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/components/AlertSnack.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { Alert } from "react-bootstrap"; 3 | 4 | export default class AlertSnack extends Component { 5 | render() { 6 | return ( 7 | 8 | {this.props.alerts.map(alert => ( 9 | 10 | {alert.title} 11 |
12 | {alert.message} 13 |
14 | ))} 15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/article/ArticleList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { APIService } from "../../services/APIService"; 4 | import { Button } from "react-bootstrap"; 5 | import RelatedArticleBadges from "./RelatedArticleBadges"; 6 | 7 | class ArticleList extends Component { 8 | state = { 9 | articles: [], 10 | deletedId: undefined, 11 | offset: 0, 12 | loadMore: false 13 | }; 14 | 15 | loadArticles = () => { 16 | APIService.getArticles(this.state.offset) 17 | .then(response => { 18 | this.setState({ 19 | offset: this.state.offset + response.results.length, 20 | articles: this.state.articles.concat(response.results) 21 | }); 22 | if (this.state.offset < response.count) { 23 | this.setState({ loadMore: true }); 24 | } else { 25 | this.setState({ loadMore: false }); 26 | } 27 | }) 28 | .catch(console.log); 29 | }; 30 | 31 | componentDidMount() { 32 | this.loadArticles(); 33 | if (this.props.location.state) { 34 | if (this.props.location.state.deletedId) { 35 | const deletedId = this.props.location.state.deletedId; 36 | this.setState({ 37 | articles: this.state.articles.filter( 38 | article => article.id !== parseInt(deletedId) 39 | ), 40 | deletedId: deletedId 41 | }); 42 | this.props.location.state.deletedId = null; 43 | } 44 | } 45 | } 46 | 47 | render() { 48 | return ( 49 |
50 | 51 | 54 | 55 |
56 | {this.state.articles && 57 | this.state.articles 58 | .filter( 59 | article => 60 | article.text !== "" && 61 | article.workspace === this.props.currentWorkspace 62 | ) 63 | .map(article => ( 64 |
65 | 66 |

{article.title}

67 | 68 | 69 |

{article.shorttext}

70 |
71 | ))} 72 | {this.state.loadMore && ( 73 | 76 | )} 77 |
78 | ); 79 | } 80 | } 81 | 82 | export default ArticleList; 83 | -------------------------------------------------------------------------------- /frontend/src/components/article/RelatedArticleBadges.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Button } from "react-bootstrap"; 4 | 5 | import { APIService } from "../../services/APIService"; 6 | 7 | export default class RelatedArticleBadges extends Component { 8 | deleteRelated = relatedArticle => { 9 | APIService.deleteRelated(this.props.currentArticle.id, relatedArticle.id); 10 | this.props.removeRelated(relatedArticle.id); 11 | }; 12 | 13 | render() { 14 | return ( 15 | 16 | {this.props.relatedArticles.map(relatedArticle => ( 17 | 18 | 22 | {relatedArticle.title} 23 | 24 | {this.props.deletable && ( 25 | 44 | )} 45 | 46 | ))} 47 | 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/article/articledetail/ArticleCreate.jsx: -------------------------------------------------------------------------------- 1 | import { withRouter } from "react-router-dom"; 2 | 3 | import ArticleEdit from "./ArticleEdit"; 4 | import { APIService } from "../../../services/APIService"; 5 | 6 | class ArticleCreate extends ArticleEdit { 7 | handleSubmit = () => { 8 | APIService.createArticle( 9 | this.state.article.title, 10 | this.state.article.text, 11 | this.props.currentWorkspace 12 | ) 13 | .then(article => { 14 | this.props.history.push("/articles/" + article.id); 15 | this.props.setDefaultView(); 16 | this.props.setArticle(article); 17 | this.props.createAlert( 18 | "Created Article", 19 | `Article ${this.state.article.title} was saved` 20 | ); 21 | }) 22 | .catch(console.log); 23 | }; 24 | } 25 | 26 | export default withRouter(ArticleCreate); 27 | -------------------------------------------------------------------------------- /frontend/src/components/article/articledetail/ArticleDetail.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Redirect } from "react-router-dom"; 3 | import { APIService } from "../../../services/APIService"; 4 | import { Button, ButtonToolbar } from "react-bootstrap"; 5 | import RelatedArticleTree from "../relatedarticletree/RelatedArticleTree"; 6 | import ArticleCreate from "./ArticleCreate"; 7 | import ArticleUpdate from "./ArticleUpdate"; 8 | import RelatedTags from "../relatedarticletags/RelatedTags"; 9 | 10 | class ArticleDetail extends Component { 11 | state = { 12 | article: { 13 | title: "", 14 | text: "", 15 | related: [] 16 | }, 17 | edit: false, 18 | new: false, 19 | redirectDeleted: false 20 | }; 21 | 22 | constructor(props) { 23 | super(props); 24 | this.setDefaultView = this.setDefaultView.bind(this); 25 | this.setArticle = this.setArticle.bind(this); 26 | } 27 | 28 | setDefaultView() { 29 | this.setState({ edit: false, new: false }); 30 | } 31 | 32 | setArticle(article) { 33 | this.setState({ article: article }); 34 | } 35 | 36 | componentDidMount() { 37 | if (this.props.location.state) { 38 | const isNew = this.props.location.state.new; 39 | if (isNew) { 40 | this.setState({ new: true }); 41 | } 42 | } else { 43 | this.loadArticle(this.props.match.params.articleId); 44 | } 45 | } 46 | 47 | componentWillReceiveProps(nextProps) { 48 | if (nextProps) { 49 | if ( 50 | nextProps.match.params.articleId !== this.props.match.params.articleId 51 | ) { 52 | this.loadArticle(nextProps.match.params.articleId); 53 | } 54 | } 55 | } 56 | 57 | loadArticle = articleId => { 58 | APIService.getArticle(articleId).then(article => { 59 | this.setState({ article: article }); 60 | }); 61 | }; 62 | 63 | handleClick = () => { 64 | this.setState({ edit: true }); 65 | }; 66 | 67 | deleteItem = () => { 68 | APIService.deleteArticle(this.props.match.params.articleId).then(() => { 69 | this.props.createAlert( 70 | "Deleted Article", 71 | `Article ${this.state.article.title} was successfully deleted` 72 | ); 73 | this.setState({ redirectDeleted: true }); 74 | }); 75 | }; 76 | 77 | removeRelated = id => { 78 | let related = this.state.article.related.filter( 79 | related => related.id !== parseInt(id) 80 | ); 81 | let article = { ...this.state.article }; 82 | article.related = related; 83 | this.setState({ article }); 84 | }; 85 | 86 | addRelated = relatedArticle => { 87 | const related = [...this.state.article.related, relatedArticle]; 88 | let article = { ...this.state.article }; 89 | article.related = related; 90 | this.setState({ article }); 91 | }; 92 | 93 | updateRelated = relatedArticle => { 94 | let article = { ...this.state.article }; 95 | article.related = [ 96 | ...this.state.article.related.filter( 97 | article => article.id !== relatedArticle.id 98 | ), 99 | relatedArticle 100 | ]; 101 | this.setState({ article }); 102 | }; 103 | 104 | render() { 105 | if (this.state.redirectDeleted) { 106 | return ( 107 | 113 | ); 114 | } 115 | if (this.state.edit) { 116 | return ( 117 | 124 | ); 125 | } else if (this.state.new) { 126 | return ( 127 | 134 | ); 135 | } else { 136 | return ( 137 |
138 | {this.state.article && ( 139 |
140 |
141 |

142 | {this.state.article.title} 143 |

144 | 145 | 150 | 153 | 166 | 167 |
168 | 176 |

{this.state.article.text}

177 | 178 |
179 | )} 180 |
181 | ); 182 | } 183 | } 184 | } 185 | 186 | export default ArticleDetail; 187 | -------------------------------------------------------------------------------- /frontend/src/components/article/articledetail/ArticleEdit.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { 3 | Button, 4 | FormGroup, 5 | ControlLabel, 6 | FormControl, 7 | HelpBlock 8 | } from "react-bootstrap"; 9 | import { APIService } from "../../../services/APIService"; 10 | 11 | export default class ArticleEdit extends Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { article: props.article, titles: [] }; 15 | } 16 | 17 | componentDidMount() { 18 | APIService.getArticleTitles().then(titles => 19 | this.setState({ 20 | titles: titles 21 | .filter( 22 | el => 23 | el.workspace === this.props.currentWorkspace && 24 | el.id !== this.state.article.id 25 | ) 26 | .map(el => el.title) 27 | }) 28 | ); 29 | } 30 | 31 | handleChange = evt => { 32 | const editArticle = Object.assign({}, this.state.article); 33 | editArticle[evt.target.name] = evt.target.value; 34 | this.setState({ article: editArticle }); 35 | }; 36 | 37 | getValidationState = () => { 38 | if ( 39 | this.state.article.title.length > 3 && 40 | !this.state.titles.includes(this.state.article.title) 41 | ) { 42 | return "success"; 43 | } else { 44 | return "error"; 45 | } 46 | }; 47 | 48 | handleSubmit = () => { 49 | console.log("handle submit"); 50 | }; 51 | 52 | render() { 53 | const disableSubmit = this.getValidationState() !== "success"; 54 | return ( 55 | 56 | {this.state.article && ( 57 |
58 |

Edit Article: {this.state.article.title}

59 |
60 | 64 | Article Title 65 | 72 | 73 | Title has to be at least 4 characters. 74 | 75 | Article Text 76 | 84 | 85 | 93 |
94 |
95 | )} 96 |
97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /frontend/src/components/article/articledetail/ArticleUpdate.jsx: -------------------------------------------------------------------------------- 1 | import { withRouter } from "react-router-dom"; 2 | 3 | import ArticleEdit from "./ArticleEdit"; 4 | import { APIService } from "../../../services/APIService"; 5 | 6 | class ArticleUpdate extends ArticleEdit { 7 | handleSubmit = () => { 8 | APIService.updateArticle( 9 | this.state.article.id, 10 | this.state.article.title, 11 | this.state.article.text, 12 | this.props.currentWorkspace 13 | ) 14 | .then(article => { 15 | this.props.setDefaultView(); 16 | this.props.setArticle(article); 17 | this.props.createAlert( 18 | "Updated Article", 19 | `Article ${this.state.article.title} was updated` 20 | ); 21 | }) 22 | .catch(console.log); 23 | }; 24 | } 25 | 26 | export default withRouter(ArticleUpdate); 27 | -------------------------------------------------------------------------------- /frontend/src/components/article/relatedarticletags/RelatedTags.jsx: -------------------------------------------------------------------------------- 1 | import { WithContext as ReactTags } from "react-tag-input"; 2 | import React, { Component } from "react"; 3 | import "./style.css"; 4 | import { APIService } from "../../../services/APIService"; 5 | import { withRouter } from "react-router-dom"; 6 | 7 | class RelatedTags extends Component { 8 | state = { 9 | suggestions: [] 10 | }; 11 | 12 | componentDidMount() { 13 | APIService.getArticleTitles().then(titles => 14 | this.setState({ 15 | suggestions: titles 16 | .filter( 17 | el => 18 | el.workspace === this.props.currentWorkspace || 19 | el.id === this.props.currentArticle.id 20 | ) 21 | .map(el => { 22 | return { id: String(el.id), title: el.title }; 23 | }) 24 | }) 25 | ); 26 | } 27 | 28 | deleteRelated = relatedArticleId => { 29 | APIService.deleteRelated(this.props.currentArticle.id, relatedArticleId); 30 | this.props.removeRelated(relatedArticleId); 31 | }; 32 | 33 | handleDelete = i => { 34 | if (window.confirm("Are you sure you wish to delete this item?")) { 35 | this.deleteRelated(this.props.relatedArticles[i].id); 36 | } 37 | }; 38 | 39 | handleAddition = tag => { 40 | console.log(tag); 41 | 42 | var label; 43 | var input = prompt("Please enter a label for this connection:", "Author"); 44 | if (input === null || input === "") { 45 | label = null; 46 | } else { 47 | label = input; 48 | } 49 | 50 | // check if duplicate tag 51 | if ( 52 | !this.props.relatedArticles.find(article => article.title === tag.title) 53 | ) { 54 | // check if article exists 55 | if (!this.state.suggestions.includes(tag)) { 56 | // create article 57 | APIService.createArticle( 58 | tag.title, 59 | "", 60 | this.props.currentWorkspace 61 | ).then(relatedArticle => { 62 | APIService.addRelated( 63 | this.props.currentArticle.id, 64 | relatedArticle.id, 65 | label 66 | ); 67 | this.props.addRelated({ 68 | id: relatedArticle.id, 69 | title: relatedArticle.title, 70 | label: label 71 | }); 72 | }); 73 | } else { 74 | APIService.getArticle(tag.id).then(relatedArticle => { 75 | APIService.addRelated( 76 | this.props.currentArticle.id, 77 | relatedArticle.id, 78 | label 79 | ); 80 | this.props.addRelated({ 81 | id: relatedArticle.id, 82 | title: relatedArticle.title, 83 | label: label 84 | }); 85 | }); 86 | } 87 | } 88 | }; 89 | 90 | handleTagClick = id => { 91 | let existingLabel = this.props.relatedArticles[id].label; 92 | if (existingLabel === null) { 93 | existingLabel = "New Label"; 94 | } 95 | 96 | let label; 97 | let input = prompt( 98 | "Please enter a new label for this connection:", 99 | existingLabel 100 | ); 101 | if (input === null || input === "") { 102 | label = null; 103 | } else { 104 | label = input; 105 | } 106 | const artilceId = this.props.currentArticle.id; 107 | const relatedArticleId = this.props.relatedArticles[id].id; 108 | 109 | console.log( 110 | `adding new labelname ${label} to existing connection between ${relatedArticleId} and ${artilceId}` 111 | ); 112 | 113 | APIService.addRelated(artilceId, relatedArticleId, label); 114 | this.props.updateRelated({ 115 | id: this.props.relatedArticles[id].id, 116 | title: this.props.relatedArticles[id].title, 117 | label: label 118 | }); 119 | }; 120 | 121 | render() { 122 | return ( 123 |
124 | { 126 | let label = relatedArticle.label; 127 | let title = label + ": " + relatedArticle.title; 128 | if (label === null) { 129 | title = relatedArticle.title; 130 | } 131 | return { 132 | id: String(relatedArticle.id), 133 | title: title 134 | }; 135 | })} 136 | suggestions={this.state.suggestions} 137 | handleDelete={this.handleDelete} 138 | handleAddition={this.handleAddition} 139 | autocomplete 140 | allowDragDrop={false} 141 | minQueryLength={1} 142 | handleTagClick={this.handleTagClick} 143 | labelField={"title"} 144 | autofocus={false} 145 | /> 146 |
147 | ); 148 | } 149 | } 150 | 151 | export default withRouter(RelatedTags); 152 | -------------------------------------------------------------------------------- /frontend/src/components/article/relatedarticletags/style.css: -------------------------------------------------------------------------------- 1 | div.ReactTags__tags { 2 | position: relative; 3 | } 4 | 5 | /* Styles for the input */ 6 | 7 | div.ReactTags__tagInput { 8 | width: 200px; 9 | border-radius: 2px; 10 | display: inline-block; 11 | } 12 | 13 | div.ReactTags__tagInput input.ReactTags__tagInputField, div.ReactTags__tagInput input.ReactTags__tagInputField:focus { 14 | height: 31px; 15 | margin: 0; 16 | font-size: 12px; 17 | width: 100%; 18 | border: 1px solid #eee; 19 | padding: 0 4px; 20 | } 21 | 22 | /* Styles for selected tags */ 23 | 24 | div.ReactTags__selected span.ReactTags__tag { 25 | border: 1px solid #ddd; 26 | background: #eee; 27 | font-size: 12px; 28 | display: inline-block; 29 | padding: 5px; 30 | margin-right: 5px; 31 | cursor: pointer; 32 | border-radius: 2px; 33 | } 34 | 35 | div.ReactTags__selected a.ReactTags__remove { 36 | color: #aaa; 37 | margin-left: 5px; 38 | cursor: pointer; 39 | } 40 | 41 | /* Styles for suggestions */ 42 | 43 | div.ReactTags__suggestions { 44 | position: absolute; 45 | } 46 | 47 | div.ReactTags__suggestions ul { 48 | list-style-type: none; 49 | box-shadow: .05em .01em .5em rgba(0, 0, 0, .2); 50 | background: white; 51 | width: 200px; 52 | } 53 | 54 | div.ReactTags__suggestions li { 55 | border-bottom: 1px solid #ddd; 56 | padding: 5px 10px; 57 | margin: 0; 58 | } 59 | 60 | div.ReactTags__suggestions li mark { 61 | text-decoration: underline; 62 | background: none; 63 | font-weight: 600; 64 | } 65 | 66 | div.ReactTags__suggestions ul li.ReactTags__activeSuggestion { 67 | background: #b7cfe0; 68 | cursor: pointer; 69 | } -------------------------------------------------------------------------------- /frontend/src/components/article/relatedarticletree/RelatedArticleTree.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { APIService } from "../../../services/APIService"; 3 | import { Treebeard, decorators } from "react-treebeard"; 4 | import { TreeStyle } from "./treebeardstyles"; 5 | import Header from "./TreebeadHeader"; 6 | 7 | export default class RelatedArticleTree extends Component { 8 | state = { 9 | treebeardData: undefined 10 | }; 11 | 12 | async componentDidMount() { 13 | if (this.props.article && this.props.article.id) { 14 | this.setState({ 15 | treebeardData: await this.getTreebeardData(this.props.article) 16 | }); 17 | } 18 | } 19 | 20 | async componentWillReceiveProps(nextProps) { 21 | if ( 22 | (nextProps && 23 | nextProps.article && 24 | nextProps.article.id && 25 | nextProps.article.id !== this.props.article.id) || 26 | nextProps.article.related !== this.props.article.related 27 | ) { 28 | this.setState({ 29 | treebeardData: await this.getTreebeardData(nextProps.article) 30 | }); 31 | } 32 | } 33 | 34 | unique(array) { 35 | return array.filter(function(a) { 36 | return !this[a] ? (this[a] = true) : false; 37 | }, {}); 38 | } 39 | 40 | getTreebeardData = async article => { 41 | const labels = this.unique(article.related.map(article => article.label)); 42 | console.log(labels); 43 | 44 | let labelChildren = {}; 45 | 46 | let children = await Promise.all( 47 | article.related.map(async related => { 48 | let children = []; 49 | let relatedArticle = await APIService.getArticle(related.id); 50 | if (relatedArticle.related.length <= 1) { 51 | children = undefined; 52 | } 53 | return this.generateRelatedNode(related, [article.id], 1, children); 54 | }) 55 | ); 56 | 57 | labels.forEach(label => { 58 | let childrenIds = article.related 59 | .filter(article => article.label === label) 60 | .map(article => article.id); 61 | labelChildren[label] = children.filter(child => 62 | childrenIds.includes(child.id) 63 | ); 64 | }); 65 | 66 | let labelBasedChildren = []; 67 | 68 | for (const [label, children] of Object.entries(labelChildren)) { 69 | const labelName = label === "null" ? "unlabeled" : label; 70 | labelBasedChildren.push({ 71 | parents: [article.id], 72 | id: label, 73 | label: true, 74 | name: labelName, 75 | toggled: true, 76 | children: children, 77 | level: 1 78 | }); 79 | } 80 | 81 | return { 82 | parent: undefined, 83 | id: article.id, 84 | name: article.title, 85 | toggled: true, 86 | children: labelBasedChildren, 87 | level: 0 88 | }; 89 | }; 90 | 91 | onToggle = (node, toggled) => { 92 | if (this.state.cursor) { 93 | this.setState({ 94 | cursor: { 95 | active: false 96 | } 97 | }); 98 | } 99 | if (node.loading) { 100 | APIService.getArticle(node.id) 101 | .then(article => { 102 | node.children = article.related.map(related => { 103 | let children = []; 104 | if (node.parents.includes(related.id)) { 105 | children = undefined; 106 | } 107 | return this.generateRelatedNode( 108 | related, 109 | [...node.parents, article.id], 110 | node.level + 1, 111 | children 112 | ); 113 | }); 114 | node.loading = false; 115 | }) 116 | .then(() => { 117 | node.active = true; 118 | if (node.children) { 119 | node.toggled = toggled; 120 | } 121 | this.setState({ cursor: node }); 122 | }); 123 | } else { 124 | if (node.children) { 125 | node.toggled = toggled; 126 | } 127 | } 128 | }; 129 | 130 | generateRelatedNode = (related, parents, level, children) => { 131 | if (!children || level > 5) { 132 | return { 133 | parents: parents, 134 | id: related.id, 135 | name: related.title 136 | }; 137 | } 138 | return { 139 | parents: parents, 140 | id: related.id, 141 | name: related.title, 142 | loading: true, 143 | children: [], 144 | level: level 145 | }; 146 | }; 147 | 148 | render() { 149 | // set custom treebeard heade 150 | decorators.Header = Header; 151 | 152 | return ( 153 |
154 |

Related Articles

155 | {this.state.treebeardData && ( 156 | 161 | )} 162 |
163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /frontend/src/components/article/relatedarticletree/TreebeadHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | 4 | class Header extends React.Component { 5 | constructor() { 6 | super(); 7 | this.handleClick = this.handleClick.bind(this); 8 | } 9 | 10 | handleClick(nodeId, e) { 11 | e.stopPropagation(); 12 | if (nodeId) { 13 | this.props.history.push(`/articles/${nodeId}`); 14 | } 15 | } 16 | 17 | render() { 18 | const { node, style } = this.props; 19 | 20 | return ( 21 |
22 | {!node.label && ( 23 |
this.handleClick(node.id, e)} 26 | > 27 | {node.name} 28 |
29 | )} 30 | {node.label && ( 31 |
32 | 33 | {node.name} 34 | 35 |
36 | )} 37 |
38 | ); 39 | } 40 | } 41 | 42 | export default withRouter(Header); 43 | -------------------------------------------------------------------------------- /frontend/src/components/article/relatedarticletree/treebeardstyles.js: -------------------------------------------------------------------------------- 1 | export const TreeStyle = { 2 | tree: { 3 | base: { 4 | listStyle: "none", 5 | margin: 0, 6 | padding: 0 7 | }, 8 | node: { 9 | container: { 10 | link: { 11 | cursor: "pointer", 12 | position: "relative", 13 | padding: "0px 5px", 14 | display: "block" 15 | }, 16 | activeLink: { 17 | background: "#31363F" 18 | } 19 | }, 20 | link: { 21 | cursor: "pointer", 22 | position: "relative", 23 | padding: "0px 5px", 24 | display: "block" 25 | }, 26 | activeLink: { 27 | background: "#31363F" 28 | }, 29 | toggle: { 30 | base: { 31 | position: "relative", 32 | display: "inline-block", 33 | verticalAlign: "top", 34 | marginLeft: "-5px", 35 | height: "24px", 36 | width: "24px" 37 | }, 38 | wrapper: { 39 | position: "absolute", 40 | top: "50%", 41 | left: "50%", 42 | margin: "-7px 0 0 -7px", 43 | height: "14px" 44 | }, 45 | height: 14, 46 | width: 14, 47 | arrow: { 48 | fill: "#9DA5AB", 49 | strokeWidth: 0 50 | } 51 | }, 52 | header: { 53 | base: { 54 | display: "inline-block", 55 | verticalAlign: "top" 56 | }, 57 | connector: { 58 | width: "2px", 59 | height: "12px", 60 | borderLeft: "solid 2px black", 61 | borderBottom: "solid 2px black", 62 | position: "absolute", 63 | top: "0px", 64 | left: "-21px" 65 | }, 66 | title: { 67 | lineHeight: "24px", 68 | verticalAlign: "middle" 69 | } 70 | }, 71 | subtree: { 72 | listStyle: "none", 73 | paddingLeft: "19px" 74 | }, 75 | loading: { 76 | color: "#E2C089" 77 | } 78 | } 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /frontend/src/components/login/Login.css: -------------------------------------------------------------------------------- 1 | .login img { 2 | width: 100%; 3 | margin-bottom: 20px; 4 | } 5 | .login, 6 | .signin { 7 | max-width: 330px !important; 8 | padding: 15px; 9 | margin: 0 auto; 10 | } 11 | 12 | .signin { 13 | margin-top: 50px; 14 | } 15 | 16 | @media screen and (max-width: 768px) { 17 | .login, 18 | .signin { 19 | padding-left: 80px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/login/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | Alert, 4 | Button, 5 | FormGroup, 6 | FormControl, 7 | ControlLabel, 8 | Image 9 | } from "react-bootstrap"; 10 | import { Redirect } from "react-router-dom"; 11 | import { AuthService } from "../../services/AuthService"; 12 | import "./Login.css"; 13 | 14 | export default class Login extends Component { 15 | constructor(props) { 16 | super(props); 17 | 18 | this.state = { 19 | username: "", 20 | password: "", 21 | alert: false 22 | }; 23 | } 24 | 25 | validateForm() { 26 | return this.state.username.length > 0 && this.state.password.length > 0; 27 | } 28 | 29 | handleChange = event => { 30 | this.setState({ 31 | [event.target.id]: event.target.value 32 | }); 33 | }; 34 | 35 | handleSubmit = event => { 36 | event.preventDefault(); 37 | AuthService.login(this.state.username, this.state.password) 38 | .then(() => { 39 | this.props.auth.userHasAuthenticated(true); 40 | }) 41 | .catch(reason => { 42 | console.log(reason); 43 | this.setState({ alert: true }); 44 | }); 45 | }; 46 | 47 | render() { 48 | const redirect = this.props.auth.isAuthenticated; 49 | if (redirect) { 50 | return ; 51 | } 52 | 53 | return ( 54 |
55 |
56 | 57 | 58 | {this.state.alert && ( 59 | 60 |

Invalid Credentials

61 |

Please verify your inputs.

62 |
63 | )} 64 | 65 | Username 66 | 72 | 73 | 74 | Password 75 | 80 | 81 | 90 |
91 |
92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /frontend/src/components/login/Register.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | Alert, 4 | Button, 5 | FormGroup, 6 | FormControl, 7 | ControlLabel 8 | } from "react-bootstrap"; 9 | import { Redirect } from "react-router-dom"; 10 | import { APIService } from "../../services/APIService"; 11 | import "./Login.css"; 12 | 13 | export default class Register extends Component { 14 | state = { 15 | username: "", 16 | password: "", 17 | password2: "", 18 | firstname: "", 19 | lastname: "", 20 | email: "", 21 | alert: false, 22 | created: false 23 | }; 24 | 25 | validateForm() { 26 | return ( 27 | this.state.username.length > 0 && 28 | this.state.password.length > 0 && 29 | this.state.firstname.length > 0 && 30 | this.state.lastname.length > 0 && 31 | this.state.email.length > 0 && 32 | this.state.password === this.state.password2 33 | ); 34 | } 35 | 36 | handleChange = event => { 37 | this.setState({ 38 | [event.target.id]: event.target.value 39 | }); 40 | }; 41 | 42 | handleSubmit = event => { 43 | event.preventDefault(); 44 | APIService.createUser( 45 | this.state.username, 46 | this.state.password, 47 | this.state.firstname, 48 | this.state.lastname, 49 | this.state.email 50 | ) 51 | .then(() => { 52 | this.setState({ created: true }); 53 | }) 54 | .catch(reason => { 55 | console.log(reason); 56 | this.setState({ alert: true }); 57 | }); 58 | }; 59 | 60 | render() { 61 | const redirect = this.state.created; 62 | if (redirect) { 63 | console.log("created user"); 64 | return ; 65 | } 66 | 67 | return ( 68 |
69 |
70 | {this.state.alert && ( 71 | Could not create user 72 | )} 73 | 74 | Username 75 | 81 | 82 | 83 | Password 84 | 89 | 90 | 91 | Password 92 | 97 | 98 | 99 | Firstname 100 | 106 | 107 | 108 | Lastname 109 | 115 | 116 | 117 | Email 118 | 124 | 125 | 134 |
135 |
136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /frontend/src/components/navigation/Navigation.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Nav, Navbar, NavItem } from "react-bootstrap"; 4 | import { AuthService } from "../../services/AuthService"; 5 | 6 | class Navigation extends Component { 7 | handleLogout = event => { 8 | this.props.auth.userHasAuthenticated(false); 9 | AuthService.logout(); 10 | }; 11 | 12 | render() { 13 | if (this.props.auth.isAuthenticated) { 14 | return null; 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | grafit.io 22 | 23 | 24 | 25 | 26 | 27 | 35 | 36 | 37 | 38 | ); 39 | } 40 | } 41 | 42 | export default Navigation; 43 | -------------------------------------------------------------------------------- /frontend/src/components/search/SearchResultPopover.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { ListGroup, ListGroupItem } from "react-bootstrap"; 3 | import { withRouter } from "react-router-dom"; 4 | 5 | class CustomListGroupItem extends ListGroupItem { 6 | renderHeader(header, headingClassName) { 7 | return ( 8 |

9 |
10 |

11 | ); 12 | } 13 | } 14 | 15 | class SearchResultPopover extends Component { 16 | constructor(className, style) { 17 | super(); 18 | this.className = className; 19 | this.style = style; 20 | } 21 | 22 | handleClick = (id, e) => { 23 | this.props.history.push(`/articles/${id}`); 24 | this.props.handleToggle(); 25 | }; 26 | 27 | render() { 28 | return ( 29 |
46 | 47 | {this.props.searchResults && 48 | this.props.searchResults.map(searchResult => ( 49 | this.handleClick(searchResult.id, e)} 54 | > 55 | 58 | 59 | ))} 60 | 61 |
62 | ); 63 | } 64 | } 65 | 66 | export default withRouter(SearchResultPopover); 67 | -------------------------------------------------------------------------------- /frontend/src/components/search/Searchbar.css: -------------------------------------------------------------------------------- 1 | .searchbar input { 2 | background-color: var(--grey-000); 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/search/Searchbar.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { 4 | Glyphicon, 5 | FormGroup, 6 | InputGroup, 7 | Button, 8 | FormControl, 9 | Overlay 10 | } from "react-bootstrap"; 11 | import SearchResultPopover from "./SearchResultPopover"; 12 | import "./Searchbar.css"; 13 | import { APIService } from "../../services/APIService"; 14 | 15 | export default class Searchbar extends Component { 16 | constructor(props, context) { 17 | super(props, context); 18 | 19 | this.handleToggle = this.handleToggle.bind(this); 20 | this.handleKeyPress = this.handleKeyPress.bind(this); 21 | this.onChange = this.onChange.bind(this); 22 | 23 | this.state = { 24 | show: false, 25 | query: "", 26 | searchResults: [] 27 | }; 28 | } 29 | 30 | handleToggle() { 31 | if (!this.state.show) { 32 | this.setSearchResults(this.state.query); 33 | } 34 | this.setState({ show: !this.state.show }); 35 | } 36 | 37 | handleKeyPress(e) { 38 | if (e.key === "Enter") { 39 | this.setSearchResults(this.state.query); 40 | this.setState({ show: true }); 41 | } 42 | } 43 | 44 | onChange(e) { 45 | this.setState({ query: e.target.value }); 46 | } 47 | 48 | setSearchResults(query) { 49 | APIService.getSearchResults(query).then(results => 50 | this.setState({ searchResults: results }) 51 | ); 52 | } 53 | 54 | render() { 55 | if (!this.props.auth.isAuthenticated) { 56 | return null; 57 | } 58 | return ( 59 |
60 | 61 | { 65 | this.target = button; 66 | }} 67 | > 68 | 74 | 75 | 83 | 84 | 85 | 86 | this.setState({ show: false })} 89 | placement="bottom" 90 | container={this} 91 | target={() => ReactDOM.findDOMNode(this.target)} 92 | rootClose={true} 93 | > 94 | 98 | 99 |
100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /frontend/src/components/workspace/CreateWorkspace.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import { 4 | FormGroup, 5 | ControlLabel, 6 | FormControl, 7 | HelpBlock, 8 | Button 9 | } from "react-bootstrap"; 10 | import { APIService } from "../../services/APIService"; 11 | 12 | class CreateWorkspace extends Component { 13 | state = { 14 | workspace: { 15 | name: "", 16 | initials: "" 17 | }, 18 | userInitials: false 19 | }; 20 | 21 | getValidationState = () => { 22 | if ( 23 | this.state.workspace.name.length >= 4 && 24 | this.state.workspace.initials.length === 2 25 | ) { 26 | return "success"; 27 | } else { 28 | return "warning"; 29 | } 30 | }; 31 | 32 | handleChange = evt => { 33 | if (evt.target.name === "initials" && !this.state.userInitials) { 34 | this.setState({ userInitials: true }); 35 | } 36 | const editWorkspace = Object.assign({}, this.state.workspace); 37 | if (evt.target.name === "name" && !this.state.userInitials) { 38 | const nameParts = evt.target.value.split(" "); 39 | if (nameParts.length >= 2 && nameParts[1].length >= 1) { 40 | editWorkspace.initials = nameParts 41 | .slice(0, 2) 42 | .map(namePart => namePart.charAt(0).toUpperCase()) 43 | .reduce((a, b) => a + b); 44 | } else { 45 | editWorkspace.initials = evt.target.value.substring(0, 2).toUpperCase(); 46 | } 47 | } 48 | editWorkspace[evt.target.name] = evt.target.value; 49 | this.setState({ workspace: editWorkspace }); 50 | }; 51 | 52 | handleSubmit = () => { 53 | APIService.createWorkspace( 54 | this.state.workspace.name, 55 | this.state.workspace.initials 56 | ) 57 | .then(() => { 58 | this.props.createAlert( 59 | this.state.workspace.initials, 60 | `Workspace ${this.state.workspace.name} created` 61 | ); 62 | this.props.refreshWorkspaces(); 63 | this.props.history.push("/"); 64 | }) 65 | .catch(console.log); 66 | }; 67 | 68 | render() { 69 | const disableSubmit = this.getValidationState() !== "success"; 70 | return ( 71 |
72 |

Create Workspace

73 |
78 | 82 | Workspace Name 83 | 90 | 91 | Name has to be at least 4 characters. 92 | 93 | Workspace Initials 94 | 101 | 102 | Initials need to be exactly two characters. 103 | 104 | 112 |
113 |
114 | ); 115 | } 116 | } 117 | 118 | export default withRouter(CreateWorkspace); 119 | -------------------------------------------------------------------------------- /frontend/src/components/workspace/UserDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Glyphicon, Dropdown, MenuItem } from "react-bootstrap"; 3 | import { withRouter } from "react-router-dom"; 4 | import { AuthService } from "../../services/AuthService"; 5 | 6 | class UserDropdown extends Component { 7 | handleLogout = event => { 8 | this.props.history.push("/"); 9 | this.props.auth.userHasAuthenticated(false); 10 | AuthService.logout(); 11 | }; 12 | 13 | handleDropDownClick() { 14 | this.dropDownFixPosition( 15 | document.getElementById("user-dropdown"), 16 | document.getElementById("user-dropdown-menu") 17 | ); 18 | } 19 | 20 | dropDownFixPosition(button, dropdown) { 21 | var rect = button.getBoundingClientRect(); 22 | 23 | var offset = { 24 | top: rect.top + document.body.scrollTop, 25 | left: rect.left + document.body.scrollLeft 26 | }; 27 | 28 | var dropDownTop = offset.top + button.offsetHeight; 29 | 30 | dropdown.style.top = dropDownTop + "px"; 31 | dropdown.style.left = offset.left + "px"; 32 | } 33 | 34 | render() { 35 | return ( 36 | 37 | { 42 | event.preventDefault(); 43 | this.handleDropDownClick(); 44 | }} 45 | > 46 | 47 | 48 | 49 | 50 | Settings 51 | 52 | 53 | Account 54 | 55 | 56 | { 59 | event.preventDefault(); 60 | this.handleLogout(); 61 | }} 62 | > 63 | Logout 64 | 65 | 66 | 67 | ); 68 | } 69 | } 70 | 71 | export default withRouter(UserDropdown); 72 | -------------------------------------------------------------------------------- /frontend/src/components/workspace/Workspace.css: -------------------------------------------------------------------------------- 1 | #sidebar { 2 | border-top: 6px solid var(--primary-500); 3 | width: inherit; 4 | min-width: 80px; 5 | max-width: 80px; 6 | background-color: var(--grey-100); 7 | border-right: 2px solid var(--grey-200); 8 | float: left; 9 | height: 100%; 10 | position: relative; 11 | overflow-y: auto; 12 | overflow-x: hidden; 13 | text-align: center; 14 | } 15 | 16 | #sidebar button { 17 | display: inline-block; 18 | width: 52px; 19 | height: 52px; 20 | font-weight: 900; 21 | margin-top: 20px; 22 | font-size: 16px; 23 | } 24 | 25 | @media screen and (max-width: 768px) { 26 | .row-offcanvas { 27 | position: relative; 28 | -webkit-transition: all 0.25s ease-out; 29 | -moz-transition: all 0.25s ease-out; 30 | transition: all 0.25s ease-out; 31 | width: calc(100% + 80px); 32 | } 33 | 34 | .row-offcanvas-left { 35 | left: -80px; 36 | } 37 | 38 | .row-offcanvas-left.active { 39 | left: 0; 40 | } 41 | 42 | .sidebar-offcanvas { 43 | position: absolute; 44 | top: 0; 45 | } 46 | } 47 | 48 | .horizontal-rule { 49 | width: 60px; 50 | margin: 20px auto 0px auto; 51 | border-bottom: 2px solid var(--grey-200); 52 | } 53 | 54 | #user-dropdown-menu { 55 | position: fixed; 56 | } 57 | 58 | #user-dropdown { 59 | background-color: var(--primary-500); 60 | } 61 | 62 | #add-workspace-btn{ 63 | background-color: var(--grey-200); 64 | } -------------------------------------------------------------------------------- /frontend/src/components/workspace/Workspace.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import { Button, Glyphicon } from "react-bootstrap"; 4 | import { APIService } from "../../services/APIService"; 5 | import UserDropdown from "./UserDropdown"; 6 | 7 | import "./Workspace.css"; 8 | 9 | class Workspace extends Component { 10 | state = { 11 | workspaces: [], 12 | activeWorkspaceId: 1 13 | }; 14 | 15 | loadWorkspaces = () => { 16 | APIService.getWorkspaces() 17 | .then(workspaces => { 18 | this.setState({ workspaces: workspaces }); 19 | // set first workspace active 20 | this.props.changeWorkspace(workspaces[0].id); 21 | }) 22 | .catch(console.log); 23 | }; 24 | 25 | createWorkspace() { 26 | this.props.history.push("/createworkspace"); 27 | } 28 | 29 | handleWorkspaceClick(workspace) { 30 | this.props.changeWorkspace(workspace); 31 | this.setState({ activeWorkspaceId: workspace }); 32 | } 33 | 34 | componentDidMount() { 35 | this.loadWorkspaces(); 36 | } 37 | 38 | componentWillReceiveProps(props) { 39 | if (props.refresh) { 40 | this.loadWorkspaces(); 41 | this.props.refreshWorkspaces(); 42 | } 43 | } 44 | 45 | render() { 46 | if (!this.props.auth.isAuthenticated) { 47 | return null; 48 | } 49 | return ( 50 | 91 | ); 92 | } 93 | } 94 | 95 | export default withRouter(Workspace); 96 | -------------------------------------------------------------------------------- /frontend/src/components/workspace/WorkspaceToggle.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | export default class WorkspaceToggle extends Component { 4 | handleWorkspaceToggle = event => { 5 | document.querySelector(".row-offcanvas").classList.toggle("active"); 6 | }; 7 | 8 | render() { 9 | if (!this.props.auth.isAuthenticated) { 10 | return null; 11 | } 12 | return ( 13 |

14 | 21 |

22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/constants.js: -------------------------------------------------------------------------------- 1 | let BASE_URL = window.location.origin.toString() + "/"; 2 | 3 | if ( 4 | window.location.hostname === "localhost" || 5 | window.location.hostname === "127.0.0.1" || 6 | window.location.hostname === "0.0.0.0" 7 | ) { 8 | BASE_URL = "http://" + window.location.hostname + ":8000/"; 9 | } 10 | 11 | export const AUTH_API = BASE_URL + "api-token-auth/"; 12 | export const API = BASE_URL + "api/v1/"; 13 | 14 | export const JWT_LOCALSTOR_KEY = "jwt"; 15 | 16 | export const ARTICLE_ENDPOINT = "articles/"; 17 | export const ARTICLETITLE_ENDPOINT = "articletitless"; 18 | export const USER_ENDPOINT = "users/"; 19 | export const WORKSPACE_ENDPOINT = "workspaces/"; 20 | export const SEARCH_ENDPOINT = "search/?searchTerm="; 21 | export const ADDRELATED_ENDPOINT = "addconcept"; 22 | export const HIDE_ENDPOINT = "hideconcept"; 23 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | padding: 0; 5 | font-family: "Quicksand", -apple-system, BlinkMacSystemFont, "Segoe UI", 6 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 7 | "Helvetica Neue", sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | height: 100%; 11 | } 12 | 13 | #root, 14 | .row-offcanvas { 15 | height: 100%; 16 | } 17 | 18 | .content { 19 | border-top:6px solid var(--primary-400); 20 | height: 100%; 21 | overflow: auto; 22 | padding: 50px; 23 | } 24 | 25 | code { 26 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 27 | monospace; 28 | } 29 | 30 | .btn-primary { 31 | background-color: var(--primary-700); 32 | color: var(--grey-100); 33 | } 34 | .btn-primary:hover, 35 | .btn-primary:focus, 36 | .btn-primary:active, 37 | .btn-primary.visited, 38 | .open > .dropdown-toggle.btn-primary { 39 | background-color: var(--primary-400); 40 | color: #ffffff; 41 | border-color: var(--primary-300); 42 | } 43 | 44 | .btn-primary.disabled, 45 | .btn-primary[disabled], 46 | fieldset[disabled] .btn-primary { 47 | background-color: var(--primary-800); 48 | } 49 | 50 | .navbar-inverse { 51 | background-color: var(--grey-900); 52 | font-weight: 900; 53 | } 54 | 55 | :root { 56 | --primary-000: hsl(258, 30%, 98%); 57 | --primary-100: hsl(258, 30%, 84%); 58 | --primary-200: hsl(258, 30%, 76%); 59 | --primary-300: hsl(258, 30%, 68%); 60 | --primary-400: hsl(258, 30%, 60%); 61 | --primary-500: hsl(258, 30%, 52%); 62 | --primary-600: hsl(258, 30%, 44%); 63 | --primary-700: hsl(258, 30%, 36%); 64 | --primary-800: hsl(258, 30%, 28%); 65 | --primary-900: hsl(258, 30%, 20%); 66 | 67 | --grey-000: hsl(0, 0%, 98%); 68 | --grey-100: hsl(0, 0%, 93%); 69 | --grey-200: hsl(0, 0%, 83%); 70 | --grey-300: hsl(0, 0%, 73%); 71 | --grey-400: hsl(0, 0%, 63%); 72 | --grey-500: hsl(0, 0%, 53%); 73 | --grey-600: hsl(0, 0%, 43%); 74 | --grey-700: hsl(0, 0%, 33%); 75 | --grey-800: hsl(0, 0%, 23%); 76 | --grey-900: hsl(0, 0%, 13%); 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import * as serviceWorker from "./serviceWorker"; 6 | 7 | ReactDOM.render(, document.getElementById("root")); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: http://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA. 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | "This web app is being served cache-first by a service " + 46 | "worker. To learn more, visit http://bit.ly/CRA-PWA" 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | installingWorker.onstatechange = () => { 64 | if (installingWorker.state === "installed") { 65 | if (navigator.serviceWorker.controller) { 66 | // At this point, the updated precached content has been fetched, 67 | // but the previous service worker will still serve the older 68 | // content until all client tabs are closed. 69 | console.log( 70 | "New content is available and will be used when all " + 71 | "tabs for this page are closed. See http://bit.ly/CRA-PWA." 72 | ); 73 | 74 | // Execute callback 75 | if (config && config.onUpdate) { 76 | config.onUpdate(registration); 77 | } 78 | } else { 79 | // At this point, everything has been precached. 80 | // It's the perfect time to display a 81 | // "Content is cached for offline use." message. 82 | console.log("Content is cached for offline use."); 83 | 84 | // Execute callback 85 | if (config && config.onSuccess) { 86 | config.onSuccess(registration); 87 | } 88 | } 89 | } 90 | }; 91 | }; 92 | }) 93 | .catch(error => { 94 | console.error("Error during service worker registration:", error); 95 | }); 96 | } 97 | 98 | function checkValidServiceWorker(swUrl, config) { 99 | // Check if the service worker can be found. If it can't reload the page. 100 | fetch(swUrl) 101 | .then(response => { 102 | // Ensure service worker exists, and that we really are getting a JS file. 103 | if ( 104 | response.status === 404 || 105 | response.headers.get("content-type").indexOf("javascript") === -1 106 | ) { 107 | // No service worker found. Probably a different app. Reload the page. 108 | navigator.serviceWorker.ready.then(registration => { 109 | registration.unregister().then(() => { 110 | window.location.reload(); 111 | }); 112 | }); 113 | } else { 114 | // Service worker found. Proceed as normal. 115 | registerValidSW(swUrl, config); 116 | } 117 | }) 118 | .catch(() => { 119 | console.log( 120 | "No internet connection found. App is running in offline mode." 121 | ); 122 | }); 123 | } 124 | 125 | export function unregister() { 126 | if ("serviceWorker" in navigator) { 127 | navigator.serviceWorker.ready.then(registration => { 128 | registration.unregister(); 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /frontend/src/services/APIService.js: -------------------------------------------------------------------------------- 1 | import { AuthService } from "./AuthService"; 2 | import { 3 | API, 4 | ARTICLE_ENDPOINT, 5 | ARTICLETITLE_ENDPOINT, 6 | USER_ENDPOINT, 7 | WORKSPACE_ENDPOINT, 8 | SEARCH_ENDPOINT, 9 | ADDRELATED_ENDPOINT, 10 | HIDE_ENDPOINT 11 | } from "../constants"; 12 | 13 | export const APIService = { 14 | callGetAPI, 15 | getArticles, 16 | getArticle, 17 | updateArticle, 18 | createArticle, 19 | deleteArticle, 20 | getArticleTitles, 21 | getWorkspaces, 22 | createWorkspace, 23 | createUser, 24 | getSearchResults, 25 | addRelated, 26 | deleteRelated 27 | }; 28 | 29 | function callGetAPI(query) { 30 | return fetch(API + query, { 31 | credentials: "omit", 32 | headers: { 33 | Authorization: "Token " + AuthService.getJWT() 34 | } 35 | }).then(response => { 36 | if (response.ok) { 37 | return response.json(); 38 | } else if (response.status === 403) { 39 | throw new Error("Not authenticated or jwt invalid"); 40 | } else { 41 | throw new Error("Something went wrong ..."); 42 | } 43 | }); 44 | } 45 | 46 | function callPostAPI(apiEndpoint, object) { 47 | return fetch(API + apiEndpoint, { 48 | credentials: "omit", 49 | headers: { 50 | Authorization: "Token " + AuthService.getJWT(), 51 | Accept: "application/json", 52 | "Content-Type": "application/json" 53 | }, 54 | method: "POST", 55 | body: JSON.stringify(object) 56 | }).then(response => { 57 | if (response.ok) { 58 | return response.json(); 59 | } else { 60 | throw new Error("Could not send post"); 61 | } 62 | }); 63 | } 64 | 65 | function callPutAPI(apiEndpoint, object) { 66 | return fetch(API + apiEndpoint, { 67 | credentials: "omit", 68 | headers: { 69 | Authorization: "Token " + AuthService.getJWT(), 70 | Accept: "application/json", 71 | "Content-Type": "application/json" 72 | }, 73 | method: "PUT", 74 | body: JSON.stringify(object) 75 | }).then(response => { 76 | if (response.ok) { 77 | return response.json(); 78 | } else { 79 | console.log(response); 80 | throw new Error("Could not send put"); 81 | } 82 | }); 83 | } 84 | 85 | function callDeleteAPI(apiEndpoint) { 86 | return fetch(API + apiEndpoint, { 87 | credentials: "omit", 88 | headers: { 89 | Authorization: "Token " + AuthService.getJWT() 90 | }, 91 | method: "DELETE" 92 | }).then(response => { 93 | if (response.ok) { 94 | return true; 95 | } else { 96 | throw new Error("Could not delete item"); 97 | } 98 | }); 99 | } 100 | 101 | function getArticles(offset = 0) { 102 | return callGetAPI(ARTICLE_ENDPOINT + "?offset=" + offset).catch(console.log); 103 | } 104 | 105 | function getArticle(id) { 106 | return callGetAPI(ARTICLE_ENDPOINT + id).catch(error => console.log(error)); 107 | } 108 | 109 | function updateArticle(id, title, text, workspace) { 110 | const article = { 111 | title: title, 112 | text: text, 113 | workspace: workspace 114 | }; 115 | return callPutAPI(ARTICLE_ENDPOINT + id + "/", article); 116 | } 117 | 118 | function createArticle(title, text, workspace) { 119 | const article = { 120 | title: title, 121 | text: text, 122 | workspace: workspace 123 | }; 124 | return callPostAPI(ARTICLE_ENDPOINT, article); 125 | } 126 | 127 | function deleteArticle(id) { 128 | return callDeleteAPI(ARTICLE_ENDPOINT + id); 129 | } 130 | 131 | function getArticleTitles() { 132 | return callGetAPI(ARTICLETITLE_ENDPOINT).catch(console.log); 133 | } 134 | 135 | function getWorkspaces() { 136 | return callGetAPI(WORKSPACE_ENDPOINT).catch(error => console.log(error)); 137 | } 138 | 139 | function createWorkspace(name, initials) { 140 | const workspace = { 141 | name: name, 142 | initials: initials 143 | }; 144 | return callPostAPI(WORKSPACE_ENDPOINT, workspace); 145 | } 146 | 147 | function createUser(username, password, firstname, lastname, email) { 148 | const user = { 149 | username: username, 150 | password: password, 151 | first_name: firstname, 152 | last_name: lastname, 153 | email: email 154 | }; 155 | 156 | return fetch(API + USER_ENDPOINT, { 157 | credentials: "omit", 158 | headers: { 159 | Accept: "application/json", 160 | "Content-Type": "application/json" 161 | }, 162 | method: "POST", 163 | body: JSON.stringify(user) 164 | }).then(response => { 165 | if (response.ok) { 166 | return response.json(); 167 | } else { 168 | throw new Error("Could not send post"); 169 | } 170 | }); 171 | } 172 | 173 | function getSearchResults(query) { 174 | return callGetAPI(SEARCH_ENDPOINT + query).catch(error => console.log(error)); 175 | } 176 | 177 | function addRelated(from, to, label) { 178 | return callPostAPI(ADDRELATED_ENDPOINT, { 179 | from: from, 180 | to: to, 181 | label: label 182 | }).catch(console.log); 183 | } 184 | 185 | function deleteRelated(from, to) { 186 | return callPostAPI(HIDE_ENDPOINT, { 187 | from: from, 188 | to: to 189 | }).catch(console.log); 190 | } 191 | -------------------------------------------------------------------------------- /frontend/src/services/AuthService.js: -------------------------------------------------------------------------------- 1 | import { AUTH_API, JWT_LOCALSTOR_KEY } from "../constants"; 2 | import { APIService } from "./APIService"; 3 | 4 | export const AuthService = { 5 | login, 6 | logout, 7 | getJWT, 8 | isLoggedIn 9 | }; 10 | 11 | function login(username, password) { 12 | return fetch(AUTH_API, { 13 | headers: { 14 | Accept: "application/json", 15 | "Content-Type": "application/json" 16 | }, 17 | method: "POST", 18 | body: JSON.stringify({ username: username, password: password }) 19 | }) 20 | .then(response => { 21 | if (response.ok) { 22 | return response.json(); 23 | } else { 24 | throw new Error("Wrong credentials"); 25 | } 26 | }) 27 | .catch(reason => { 28 | throw reason; 29 | }) 30 | .then(data => { 31 | let token = data["token"]; 32 | console.log("Recieved JWT: " + token); 33 | localStorage.setItem(JWT_LOCALSTOR_KEY, token); 34 | }) 35 | .catch(reason => { 36 | throw reason; 37 | }); 38 | } 39 | 40 | function isJWTValid(jwt) { 41 | return APIService.callGetAPI("") 42 | .then(() => { 43 | return true; 44 | }) 45 | .catch(() => { 46 | return false; 47 | }); 48 | } 49 | 50 | function logout() { 51 | // remove jwt from local storage to log user out 52 | localStorage.removeItem(JWT_LOCALSTOR_KEY); 53 | } 54 | 55 | function getJWT() { 56 | return localStorage.getItem(JWT_LOCALSTOR_KEY); 57 | } 58 | 59 | function isLoggedIn() { 60 | if (getJWT() !== null) { 61 | return isJWTValid(getJWT()); 62 | } 63 | return Promise.resolve(false); 64 | } 65 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: grafit.io 2 | site_description: Shared Knowledge 3 | repo_url: https://github.com/grafit-io/grafit 4 | site_dir: site 5 | copyright: Copyright © 2018, grafit. 6 | dev_addr: 0.0.0.0:8001 7 | docs_dir: "mkdocs/docs" 8 | 9 | nav: 10 | - Home: "index.md" 11 | - User Guide: 12 | - Sign Up/ Login: "userdoc/signup.md" 13 | - Create Workspace: "userdoc/createworkspace.md" 14 | - Create Articles: "userdoc/createarticle.md" 15 | - Search Articles: "userdoc/search.md" 16 | - Developer Guide: 17 | - Quckstart: "developerdoc/quickstart.md" 18 | - Containers: "developerdoc/containers.md" 19 | - API-Authentication: "developerdoc/api/authentication.md" 20 | - Neo4J: "developerdoc/neo4j.md" 21 | - CI/ CD: "developerdoc/ci.md" 22 | 23 | theme: 24 | name: readthedocs 25 | -------------------------------------------------------------------------------- /mkdocs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | LABEL Name=grafit_docs Version=0.0.1 4 | EXPOSE 8001 5 | 6 | COPY ./requirements.txt requirements.txt 7 | # Using pip 8 | RUN pip install -r requirements.txt 9 | 10 | COPY . /code 11 | WORKDIR /code 12 | 13 | CMD mkdocs serve 14 | -------------------------------------------------------------------------------- /mkdocs/docs/developerdoc/api/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | All requests to the REST-API with the exception of the login request need to be authenticated. For clients to authenticate, the token key should be included in the Authorization HTTP header. The key should be prefixed by the string literal "Token", with whitespace separating the two strings. For example: 4 | 5 | ``` 6 | Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b 7 | ``` 8 | 9 | Unauthenticated responses that are denied permission will result in an HTTP `401 Unauthorized` response with an appropriate `WWW-Authenticate` header. For example: 10 | 11 | ``` 12 | WWW-Authenticate: Token 13 | ``` 14 | 15 | The curl command line tool may be useful for testing token authenticated APIs. For example: 16 | 17 | ```bash 18 | curl -X GET http://127.0.0.1:8000/api/v1/example/ -H 'Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b' 19 | ``` 20 | 21 | ## Retrieving Tokens 22 | 23 | Authorization tokens are issued and returned when a user registers. A registered user can also retrieve their token with the following request: 24 | 25 | **Request**: 26 | 27 | `POST` `api-token-auth/` 28 | 29 | Parameters: 30 | 31 | | Name | Type | Description | 32 | | -------- | ------ | ------------------- | 33 | | username | string | The user's username | 34 | | password | string | The user's password | 35 | 36 | **Response**: 37 | 38 | ```json 39 | { 40 | "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b" 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /mkdocs/docs/developerdoc/ci.md: -------------------------------------------------------------------------------- 1 | # Continuous Deployment 2 | 3 | Continuous deployment and integration is implemented with [Travis CI](https://travis-ci.com/). The build status can be seen under [https://travis-ci.com/grafit-io/grafit](https://travis-ci.com/grafit-io/grafit). 4 | The buildscript is in the GitHub repository under [.travis.yml](https://github.com/grafit-io/grafit/blob/master/.travis.yml) 5 | -------------------------------------------------------------------------------- /mkdocs/docs/developerdoc/containers.md: -------------------------------------------------------------------------------- 1 | # Containerization 2 | 3 | Grafit is split up in five different docker containers. 4 | 5 | ![Docker Containers](../img/DockerDeployment.png "Docker Containers") 6 | 7 | When using the production docker-compose file the frontend (port 3000) and the doc server (port 8001) as well as the backend(port 8000), that is used by the user via the frontend, are exposed to the network. 8 | If the docker-compose.dev.yml is used, the webinterface of the Neo4J container is exposed on port 7474. 9 | -------------------------------------------------------------------------------- /mkdocs/docs/developerdoc/neo4j.md: -------------------------------------------------------------------------------- 1 | # Neo4J 2 | 3 | The Neo4J database is used to store the knowledge graph. 4 | 5 | ## Webinterface 6 | 7 | When starting the docker containers using the docker-compose.dev.yml the Neo4J webinterface is exposed on port 7474. 8 | 9 | ## Management Commands 10 | 11 | The following management commands can be used: 12 | 13 | ### install_labels 14 | 15 | This command sets up the constraints and indexes based on the neomodel definitions. 16 | 17 | ```bash 18 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py install_labels 19 | ``` 20 | 21 | ### clear_neo4j 22 | 23 | This command deletes all nodes in the Neo4J database 24 | 25 | ```bash 26 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py clear_neo4j 27 | ``` 28 | -------------------------------------------------------------------------------- /mkdocs/docs/developerdoc/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | To get started developing you need to install the following: 4 | 5 | - Docker [Mac](https://docs.docker.com/docker-for-mac/install/) / [Windows](https://docs.docker.com/docker-for-windows/install/) (with docker-compose) 6 | 7 | ## Initialize the project 8 | 9 | Use the following commands to initialize the local development environment: 10 | 11 | ```bash 12 | # Apply DB migrations 13 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py migrate 14 | 15 | # Create a superuser 16 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py createsuperuser 17 | 18 | # Start containers 19 | docker-compose -f docker-compose.dev.yml up 20 | ``` 21 | 22 | To generate migration scripts for new database changes run: 23 | 24 | ```bash 25 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py makemigrations 26 | ``` 27 | 28 | To run tests: 29 | 30 | ```bash 31 | docker-compose -f docker-compose.dev.yml run --rm backend ./manage.py test 32 | ``` 33 | -------------------------------------------------------------------------------- /mkdocs/docs/img/DockerDeployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/DockerDeployment.png -------------------------------------------------------------------------------- /mkdocs/docs/img/articlecreated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/articlecreated.png -------------------------------------------------------------------------------- /mkdocs/docs/img/color_logo_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/color_logo_transparent.png -------------------------------------------------------------------------------- /mkdocs/docs/img/createworkspace_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/createworkspace_form.png -------------------------------------------------------------------------------- /mkdocs/docs/img/createworkspace_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/createworkspace_start.png -------------------------------------------------------------------------------- /mkdocs/docs/img/createworkspace_success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/createworkspace_success.png -------------------------------------------------------------------------------- /mkdocs/docs/img/inputconnection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/inputconnection.png -------------------------------------------------------------------------------- /mkdocs/docs/img/inputlabel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/inputlabel.png -------------------------------------------------------------------------------- /mkdocs/docs/img/labelgrouping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/labelgrouping.png -------------------------------------------------------------------------------- /mkdocs/docs/img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/login.png -------------------------------------------------------------------------------- /mkdocs/docs/img/newarticleform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/newarticleform.png -------------------------------------------------------------------------------- /mkdocs/docs/img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/search.png -------------------------------------------------------------------------------- /mkdocs/docs/img/signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafit-io/grafit/2823fec3905d7c9cde48cd787224bcc3b7454c0f/mkdocs/docs/img/signup.png -------------------------------------------------------------------------------- /mkdocs/docs/index.md: -------------------------------------------------------------------------------- 1 | ![Logo](img/color_logo_transparent.png) 2 | 3 | [![Build Status](https://travis-ci.com/grafit-io/grafit.svg?branch=master)](https://travis-ci.com/grafit-io/grafit) 4 | [![codecov](https://codecov.io/gh/grafit-io/grafit/branch/master/graph/badge.svg)](https://codecov.io/gh/grafit-io/grafit) 5 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit?ref=badge_shield) 6 | 7 | grafit is an MIT-licensed web app that allows teams to store, share and search knowledge in an effective way. With intelligent relation detection, different data sources are presented and searchable at one central entry point. 8 | 9 | ## User Guide 10 | 11 | The user documentation is available [here](userdoc/signup.md). 12 | 13 | ## Developer Guide 14 | 15 | The documentation for developers is available [here](developerdoc/quickstart.md). 16 | If you want to contribute to this project please follow the contributing guidelines [here](https://github.com/grafit-io/grafit/blob/master/CONTRIBUTING.md). 17 | 18 | ## Deployment 19 | 20 | Grafit can be deployed via a docker-compose file. The prerequisite for this is that you have Docker and Docker-Compose installed on the target machine. 21 | Instructions can be found here: 22 | Install Docker [Mac](https://docs.docker.com/docker-for-mac/install/) / [Windows](https://docs.docker.com/docker-for-windows/install/) 23 | 24 | Once Docker is installed the current production docker-compose file can be downloaded with the following curl command: 25 | 26 | ```bash 27 | curl https://raw.githubusercontent.com/grafit-io/grafit/master/docker-compose.yml -o docker-compose.yml 28 | ``` 29 | 30 | Next all containers can be startet with the docker-compose command: 31 | 32 | ```bash 33 | docker-compose up 34 | ``` 35 | 36 | Once all containers have started the following actions can be taken: 37 | 38 | - Go to [http://localhost:3000](http://localhost:3000) to start the app. 39 | - Go to [http://localhost:8001](http://localhost:8001) to read the docs. 40 | 41 | More about the different containers can be read [here](developerdoc/containers.md). 42 | 43 | ## License 44 | 45 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fgrafit-io%2Fgrafit?ref=badge_large) 46 | -------------------------------------------------------------------------------- /mkdocs/docs/userdoc/createarticle.md: -------------------------------------------------------------------------------- 1 | ## Create a New Article 2 | 3 | To create a new article click the "Create New Article" button below the search bar. A form opens up where the title for the article as well as the text can be set. The article has to be unique on a per workspace basis. 4 | 5 | ![New Article Form](../img/newarticleform.png) 6 | 7 | Any links in the text will be followed, the content crawled and added to the serach index. 8 | The most important keyphrases in your text are extracted using natural language processing algorithms. The article is then connected to those keyphrases. The result looks as follows. 9 | 10 | ![Article Created](../img/articlecreated.png) 11 | 12 | Marked in orange are the connected keyphrases. The keyphrases are articles too and you can navigate to them using the tree view below the article. 13 | 14 | ## Create a New Connection between Articles 15 | 16 | If you want to manually add another connection to the existing ones you can easily do so by typing in the "Add new tag" box marked in orange in the image below. 17 | ![Create a New Connection](../img/inputconnection.png) 18 | 19 | When typing in the input field you will notice, that there is autocomplete assisting you to find existing articles. If an article does not yet exist you simply type out the desired name and press enter. A new article with the corresponding name will then be created for you. 20 | Once connection to the new article is created an input field appears asking you to label this connection. 21 | 22 | ![Label Connection](../img/inputlabel.png) 23 | 24 | Don't worry if you don't know a name for the connection. Simply let the input field empty and click "OK". 25 | 26 | ## Label existing Connection 27 | 28 | If you want to add or edit a label of an existing connection you can do so by clicking on the tag of that connection. Similar to when creating a new connection you are then asked to input a new label for that connection. 29 | 30 | Adding labels to the connections can be very valuable, as the connections are grouped in the tree view below the article text. You will find the connections where no label is set under "unlabeled". 31 | 32 | ![Connections Grouped by Label](../img/labelgrouping.png) 33 | -------------------------------------------------------------------------------- /mkdocs/docs/userdoc/createworkspace.md: -------------------------------------------------------------------------------- 1 | # Create a New Workspace 2 | 3 | A new workspace can be created by clicking on the plus button in the workspace bar to the left. 4 | ![Click New Workspace](../img/createworkspace_start.png) 5 | 6 | After clicking that button, a form appears that allows you to input your workspace name as well as your workspace initials. By default the initials are the starting letter of the first two words appearing in the workspace name. 7 | ![Fill New Workspace Form](../img/createworkspace_form.png) 8 | 9 | When you click save a alert bar will appera and inform you that the workspace was created successfully. 10 | ![New Workspace Created](../img/createworkspace_success.png) 11 | -------------------------------------------------------------------------------- /mkdocs/docs/userdoc/index.md: -------------------------------------------------------------------------------- 1 | # User Documentation 2 | -------------------------------------------------------------------------------- /mkdocs/docs/userdoc/search.md: -------------------------------------------------------------------------------- 1 | # Search 2 | 3 | When using grafit, you will notice that a very prominent UI element is the search bar that is on the top of almost every page. 4 | With the search bar you can search for articles using their title, text or even link content. If you paste a URL into the text of an article, the URL will be crawled and the content added to the search index. 5 | In the example below you will see that we search for "cross-project" and the article "Knowledge Management" is shown as the top search result. 6 | 7 | ![Serach](../img/search.png) 8 | 9 | As you will see in the image below, the phrase "cross-project" does not appear in the article text. Instead it appears in the Wikipedia article that is linked. 10 | 11 | ![Article Content](../img/labelgrouping.png) 12 | -------------------------------------------------------------------------------- /mkdocs/docs/userdoc/signup.md: -------------------------------------------------------------------------------- 1 | This user guide will show you all the necessary steps to get started using [_**grafit**_](https://github.com/grafit-io/grafit/). 2 | 3 | ## Sign Up 4 | 5 | By clicking the sign up Button you can register yourself. 6 | 7 | ![Sign Up](../img/signup.png) 8 | 9 | ## Login 10 | 11 | After the sign up is completed you can login via the login button in the top bar. 12 | 13 | ![Login](../img/login.png) 14 | -------------------------------------------------------------------------------- /mkdocs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: grafit.io 2 | site_description: Shared Knowledge 3 | repo_url: https://github.com/grafit-io/grafit 4 | site_dir: site 5 | copyright: Copyright © 2018, grafit. 6 | dev_addr: 0.0.0.0:8001 7 | 8 | nav: 9 | - Home: "index.md" 10 | - User Guide: 11 | - Sign Up/ Login: "userdoc/signup.md" 12 | - Create Workspace: "userdoc/createworkspace.md" 13 | - Create Articles: "userdoc/createarticle.md" 14 | - Search Articles: "userdoc/search.md" 15 | - Developer Guide: 16 | - Quckstart: "developerdoc/quickstart.md" 17 | - Containers: "developerdoc/containers.md" 18 | - API-Authentication: "developerdoc/api/authentication.md" 19 | - Neo4J: "developerdoc/neo4j.md" 20 | - CI/ CD: "developerdoc/ci.md" 21 | 22 | theme: 23 | name: readthedocs 24 | -------------------------------------------------------------------------------- /mkdocs/requirements.txt: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | Jinja2==2.10 3 | livereload==2.5.2 4 | Markdown==3.0.1 5 | MarkupSafe==1.0 6 | mkdocs==1.0.4 7 | PyYAML==3.13 8 | six==1.11.0 9 | tornado==5.1.1 10 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.6 6 | 7 | # Don't build any extra formats 8 | formats: [] 9 | 10 | requirements_file: mkdocs/requirements.txt 11 | --------------------------------------------------------------------------------