├── .coveragerc ├── .gitattributes ├── .gitignore ├── .gitlab-ci.yml ├── .pylintrc ├── .readthedocs.yaml ├── CHANGELOG ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── PITCHME.md ├── PITCHME.yaml ├── README.rst ├── assets ├── css │ └── PITCHME.css ├── images │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── Banner_Logo_yourLabs_White_13_Copy_9_bkheoj.webp │ ├── CRUDFLAP_HOME.png │ ├── Post_create.png │ ├── YourLabs_Logo_Simple_transparent_wwbd35.webp │ ├── dir_str.jpeg │ ├── dir_str_1.png │ ├── gitpitch-audience.jpg │ ├── in-60-seconds.jpg │ ├── post_list.png │ ├── post_menu.png │ └── update_post.png └── sample │ ├── crudlfap.py │ ├── models.py │ ├── settings.py │ └── urls.py ├── conftest.py ├── docker-compose.persist.yml ├── docker-compose.traefik.yml ├── docker-compose.yml ├── docs ├── Makefile ├── conf.py ├── crudlfap_auth │ ├── index.rst │ └── views.rst ├── factory.rst ├── index.rst ├── install.rst ├── requirements.txt ├── route.rst ├── router.rst ├── screenshots │ ├── add-modal.png │ ├── detail-template.png │ ├── list.png │ └── update-form.png ├── settings.rst ├── tutorial.rst └── views.rst ├── manage.py ├── nightwatch ├── eslintrc.json ├── globals-driver.js ├── globals.js ├── nightwatch-driver.json ├── nightwatch.conf.js ├── package.json ├── shared │ ├── CONSTANTS.ts │ └── commonFunction.ts ├── tests │ ├── artist │ │ ├── artistAction.ts │ │ └── validation.ts │ ├── group │ │ ├── groupAction.ts │ │ └── validation.ts │ ├── login.ts │ ├── logout.ts │ ├── menu.ts │ ├── post │ │ ├── postAction.ts │ │ └── validation.ts │ ├── songRating │ │ └── validation.ts │ ├── songs │ │ ├── songAction.ts │ │ └── validation.ts │ └── user │ │ ├── userAction.ts │ │ └── validation.ts └── tsconfig.json ├── pytest.ini ├── readthedocs.yml ├── setup.py ├── src ├── crudlfap │ ├── __init__.py │ ├── apps.py │ ├── conf.py │ ├── crudlfap.py │ ├── factory.py │ ├── html.py │ ├── locale │ │ └── fr │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ └── __init__.py │ ├── mixins │ │ ├── __init__.py │ │ ├── crud.py │ │ ├── filter.py │ │ ├── form.py │ │ ├── lock.py │ │ ├── menu.py │ │ ├── model.py │ │ ├── modelform.py │ │ ├── object.py │ │ ├── objectform.py │ │ ├── objects.py │ │ ├── objectsform.py │ │ ├── search.py │ │ ├── table.py │ │ └── template.py │ ├── models.py │ ├── registry.py │ ├── route.py │ ├── router.py │ ├── settings.py │ ├── shortcuts.py │ ├── site.py │ ├── static │ │ ├── swagger-ui-bundle.js │ │ ├── swagger-ui-standalone-preset.js │ │ └── swagger-ui.css │ ├── test_conf.py │ ├── test_factory.py │ ├── test_route.py │ ├── test_router.py │ ├── utils.py │ └── views │ │ ├── __init__.py │ │ ├── api.py │ │ └── generic.py ├── crudlfap_auth │ ├── __init__.py │ ├── backends.py │ ├── crudlfap.py │ ├── html.py │ ├── locale │ │ └── fr │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── models.py │ ├── test_urls.py │ └── views.py ├── crudlfap_example │ ├── __init__.py │ ├── artist │ │ ├── __init__.py │ │ ├── crudlfap.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ └── test_api.py │ ├── blog │ │ ├── crudlfap.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ └── test_security.py │ ├── manage.py │ ├── settings.py │ ├── song │ │ ├── __init__.py │ │ ├── crudlfap.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ └── test_security.py │ ├── urls.py │ └── wsgi.py ├── crudlfap_registration │ ├── __init__.py │ ├── crudlfap.py │ ├── html.py │ ├── templates │ │ └── django_registration │ │ │ ├── activation_email_body.txt │ │ │ └── activation_email_subject.txt │ └── test_registration.py └── crudlfap_sites │ ├── __init__.py │ ├── crudlfap.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_site_settings.py │ └── __init__.py │ └── models.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = .tox 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain about missing debug-only code: 12 | def __repr__ 13 | if self\.debug 14 | 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Don't complain if non-runnable code isn't run: 20 | if 0: 21 | if __name__ == .__main__.: 22 | 23 | ignore_errors = True 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/crudlfap/static/* binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | ANSIBLE_FORCE_COLOR: 'true' 3 | RELEASE_TAG: yourlabs/crudlfap:$CI_COMMIT_REF_NAME 4 | IMAGE_TAG: yourlabs/crudlfap:$CI_COMMIT_SHA 5 | 6 | build: 7 | stage: build 8 | image: docker:stable 9 | before_script: 10 | - echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin 11 | script: 12 | - docker pull yourlabs/crudlfap:master || true 13 | - docker build -t $IMAGE_TAG . 14 | - docker push $IMAGE_TAG 15 | after_script: 16 | - docker logout 17 | 18 | build-test: 19 | image: 20 | name: $IMAGE_TAG 21 | entrypoint: ["/bin/sh", "-c"] 22 | stage: test 23 | script: 24 | - pip install -e .[project] 25 | - pip install pytest pytest-cov pytest-asyncio pytest-django 26 | - pytest -vv --cov src --cov-report=xml:coverage.xml --cov-report=term-missing --strict -r fEsxXw src 27 | 28 | .deploy: &deploy 29 | before_script: 30 | - mkdir -p ~/.ssh; echo "$CI_SSH_KEY" > ~/.ssh/id_ed25519; echo "$SSH_FINGERPRINTS" > ~/.ssh/known_hosts; chmod 700 ~/.ssh; chmod 600 ~/.ssh/* 31 | script: 32 | - set -x 33 | - export HOST=$(echo $CI_ENVIRONMENT_URL | sed s@^.*://@@) 34 | - export PROTO=$(echo $CI_ENVIRONMENT_URL | sed s@:.*@@) 35 | - export CI_PROJECT_SLUG=$(echo $CI_PROJECT_NAME | sed s@[/.]@-@g) 36 | - export ANSIBLE_HOST_KEY_CHECKING=False 37 | - bigsudo yourlabs.compose 38 | compose_django_build= 39 | compose_django_image=$IMAGE_TAG 40 | wait_grep=uwsgi 41 | pull=no 42 | $DEPLOY 43 | crudlfap@ci.yourlabs.io 44 | | tee deploy.log 45 | - grep unreachable=0 deploy.log &> /dev/null 46 | - grep failed=0 deploy.log &> /dev/null 47 | 48 | review-deploy: 49 | image: yourlabs/ansible 50 | stage: test 51 | environment: 52 | name: test/$CI_COMMIT_REF_NAME 53 | url: http://${CI_ENVIRONMENT_SLUG}.crudlfap.ci.yourlabs.io 54 | variables: 55 | DEPLOY: > 56 | compose=docker-compose.yml,docker-compose.traefik.yml 57 | lifetime=86400 58 | project=$CI_ENVIRONMENT_SLUG 59 | except: 60 | - tags 61 | - master 62 | <<: *deploy 63 | 64 | master-deploy: 65 | image: yourlabs/ansible 66 | stage: deploy 67 | environment: 68 | name: master 69 | url: https://master.crudlfap.ci.yourlabs.io 70 | variables: 71 | DEPLOY: > 72 | compose=docker-compose.yml,docker-compose.traefik.yml,docker-compose.persist.yml 73 | home=/home/crudlfap-master 74 | only: 75 | refs: 76 | - master 77 | <<: *deploy 78 | 79 | demo: 80 | image: yourlabs/ansible 81 | stage: deploy 82 | environment: 83 | name: demo 84 | url: https://demo.crudlfap.ci.yourlabs.io 85 | variables: 86 | DEPLOY: > 87 | compose=docker-compose.yml,docker-compose.traefik.yml,docker-compose.persist.yml 88 | home=/home/crudlfap-demo 89 | only: [tags] 90 | <<: *deploy 91 | 92 | docs: 93 | stage: build 94 | image: yourlabs/python-arch 95 | artifacts: 96 | expire_in: 2 days 97 | when: always 98 | paths: [public] 99 | script: 100 | - pip install -r docs/requirements.txt 101 | - pushd docs && make html && popd 102 | - mv docs/_build/html public 103 | 104 | py-qa: 105 | stage: build 106 | image: yourlabs/python-arch 107 | script: tox -e qa 108 | 109 | py-test: 110 | stage: build 111 | image: yourlabs/python-arch 112 | script: 113 | - tox -e py310-dj40 114 | - codecov-bash -e TOXENV -f coverage.xml 115 | 116 | pypi: 117 | stage: deploy 118 | image: yourlabs/python-arch 119 | script: pypi-release 120 | only: [tags] 121 | 122 | pages: 123 | stage: test 124 | image: alpine:latest 125 | artifacts: 126 | paths: [public] 127 | only: 128 | - master 129 | script: find public 130 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [DESIGN] 2 | max-parents=15 3 | disable=invalid-name 4 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | build: 2 | image: testing 3 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.7.0 2 | 3 | NEW FEATURES 4 | 5 | - Non-db model support has been greatly improved, using the lookupy module that 6 | implements a QuerySet interface for python objects, we just added support for 7 | models that have managed=False, 8 | - You can now browse your list of registered `Routers` and `Routes` in 9 | CRUDLFA+, these are auto-generated during runtime into 10 | `crudlfap.models.Controller` and `crudlfap.models.URL`, 11 | - By doing so, you can authorize groups that should have the view permission in 12 | each view's detail page, refer to the new documentation about security model 13 | for details, 14 | - As such, the poor urls debug view is gone in favor of the above, 15 | - Django permission API is now the default way of dealing with permission, 16 | - BC Breaks listed below 17 | 18 | BACKWARD COMPATIBILITY BREAKS 19 | 20 | So, basically this will make your 0.5 CRUDLFA+ project start correctly on 0.6:: 21 | 22 | find src/ -type f | xargs sed -i 's/from crudlfap import crudlfap/from crudlfap import shortcuts as crudlfap/' 23 | 24 | BUT all your views will be invisible to non-superusers. Follow these steps to 25 | upgrade: 26 | 27 | - `from crudlfap import crudlfap` should now be `from crudlfap import shortcuts 28 | as crudlfap`, 29 | - `allowed` is gone, in favor of `has_perm()`, that checks django permission by 30 | default, that means views are not open to staff users by default but to 31 | superusers 32 | - as such, to open a view to all, replace `allowed=True` with 33 | `authenticate=False`, 34 | - `Router.get_objects_for_user(user, perms)` is gone in favor of 35 | `Router.get_queryset(view)`, which returns all models by default. 36 | - `Router.get_fields_for_user(user, perms, obj=None)` becomes 37 | `Router.get_fields(view)`, 38 | - `Route.short_permission_code` becomes `Route.permission_shortcode`, 39 | - `Route.full_permission_code` becomes `Route.permission_fullcode`, 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux 2 | 3 | ENV DJANGO_SETTINGS_MODULE=crudlfap_example.settings 4 | ENV UWSGI_MODULE=crudlfap_example.wsgi:application 5 | 6 | ENV NODE_ENV=production 7 | ENV PATH="${PATH}:/app/.local/bin" 8 | ENV PYTHONIOENCODING=UTF-8 PYTHONUNBUFFERED=1 STATIC_ROOT=/app/public 9 | EXPOSE 8000 10 | 11 | RUN pacman -Syu --noconfirm mailcap which gettext python python-pillow python-psycopg2 python-pip python-psutil git curl uwsgi uwsgi-plugin-python python python-hiredis libsass && pip install --break-system-packages --upgrade pip djcli 12 | RUN useradd --home-dir /app --uid 1000 app && mkdir -p /app && chown -R app /app 13 | WORKDIR /app 14 | 15 | COPY setup.py README.rst MANIFEST.in /app/ 16 | COPY src /app/src 17 | COPY manage.py /app 18 | RUN pip install --break-system-packages --editable /app[project] 19 | 20 | RUN ./manage.py ryzom_bundle 21 | RUN DEBUG=1 ./manage.py collectstatic --noinput 22 | RUN find public -type f | xargs gzip -f -k -9 23 | 24 | USER app 25 | 26 | ARG GIT_COMMIT 27 | ARG GIT_TAG 28 | ENV GIT_COMMIT="${GIT_COMMIT}" GIT_TAG="${GIT_TAG}" 29 | 30 | CMD bash -c "djcli dbcheck && ./manage.py migrate --noinput && uwsgi \ 31 | --http-socket=0.0.0.0:8000 \ 32 | --chdir=/app \ 33 | --plugin=python \ 34 | --module=${UWSGI_MODULE} \ 35 | --http-keepalive \ 36 | --harakiri=120 \ 37 | --max-requests=100 \ 38 | --master \ 39 | --workers=8 \ 40 | --processes=4 \ 41 | --chmod=666 \ 42 | --log-5xx \ 43 | --vacuum \ 44 | --enable-threads \ 45 | --post-buffering=8192 \ 46 | --ignore-sigpipe \ 47 | --ignore-write-errors \ 48 | --disable-write-exception \ 49 | --mime-file /etc/mime.types \ 50 | --route '^/static/.* addheader:Cache-Control: public, max-age=7776000' \ 51 | --route '^/js|css|fonts|images|icons|favicon.png/.* addheader:Cache-Control: public, max-age=7776000' \ 52 | --static-map /static=/app/public \ 53 | --static-map /media=/app/media \ 54 | --static-gzip-all" 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 YourLabs. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | 3. All advertising materials mentioning features or use of this software must display the following acknowledgement: 8 | This product includes software developed by the organization. 9 | 4. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | recursive-include src *.html *.css *.js *.py *.po *.mo *.txt 3 | -------------------------------------------------------------------------------- /PITCHME.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | @title[CRUDLFA] 4 | 5 | ![Logo](assets/images/YourLabs_Logo_Simple_transparent_wwbd35.webp) 6 | 7 | ## @color[#DC143C](CRUDLFA+) 8 | 9 | 10 | CRUDLFA+ stands for Create Read Update Delete List Form Autocomplete and more. 11 | 12 | --- 13 | 14 | ### @css[crudlfa-headline](prerequisite) 15 | 16 | - pip | 17 | - pip is a package manager for Python packages, or modules if you like. | 18 | - Virtualenv | 19 | - virtualenv is a tool to create isolated Python environments. | 20 | 21 | --- 22 | ### @color[#DC143C](Django) 23 | Django is a free and open source web application framework written in Python. A framework is nothing more than a collection of modules that make development easier. 24 | 25 | --- 26 | 27 | ### @color[#DC143C](Installation of Django) 28 | pip install django 29 | 30 | --- 31 | ### @color[#DC143C](Create Django Project) 32 | - startproject | 33 | django-admin startproject mysite 34 | - Directory structure | 35 | ![Logo](assets/images/dir_str.jpeg) 36 | 37 | Note: 38 | - This will create a mysite directory in your current directory, and the structure would like this. 39 | 40 | --- 41 | ### @color[#DC143C](Create post application) 42 | - startapp | 43 | django-admin startapp post 44 | - Application Structure | 45 | ![Logo](assets/images/dir_str_1.png) 46 | 47 | Note: 48 | - This will create a post application in inside mysite directory. 49 | 50 | 51 | --- 52 | ### @color[#DC143C](default View) 53 | - Run Server | 54 | python manage.py runserver 55 | - You can access your application using the following URL. | 56 | http://127.0.0.1:8000 57 | 58 | Note: 59 | - This is the default view of the django application. 60 | - Now we will learn CRUD operation using CRUDLFAP. 61 | 62 | --- 63 | ### @css[crudlfa-headline](Integration of CRUDLFA+) 64 | 65 | - You can install CRUDLFA+ by the following ways. | 66 | - Installing from pip | 67 | pip install crudlfap 68 | - If you are not in a virtualenv, the above will fail if not executed as root, in this case use | 69 | pip install --user crudlfap 70 | 71 | ---?code=assets/sample/settings.py&lang=python&title=@css[crudlfa-headline](Integration of CRUDLFA+) 72 | @[14-15](import @color[#DC143C](CRUDLFAP_APPS) and @color[#DC143C](CRUDLFAP_TEMPLATE_BACKEND) from @color[#DC143C](crudlfap)) 73 | @[34-42](Add @color[#DC143C](CRUDLFAP_APPS) with installed app) 74 | @[57-69](Add the @color[#DC143C](CRUDLFAP_TEMPLATE_BACKEND) line inside TEMPLATES.) 75 | 76 | 77 | ---?code=assets/sample/urls.py&lang=python&title=@css[crudlfa-headline](piece of code we need to add in @color[#DC143C](urls.py) file) 78 | @[18](import @color[#DC143C](crudlfap) from @color[#DC143C](crudlfap)) 79 | @[20-23](add @color[#DC143C](crudlfap) urls in urlpatterns) 80 | 81 | 82 | --- 83 | ### Using @color[#DC143C](CRUDLFA+) 84 | - Register custom application with Django | 85 | INSTALLED_APPS = [ 86 | ..... 87 | 'post', 88 | ] 89 | 90 | Note: 91 | - Now we will register the post application that we created inside settings module. 92 | - We just need to mention this app name inside INSTALLED_APPS list. 93 | 94 | ---?code=assets/sample/models.py&lang=python&title=@color[#DC143C](Models in Djano) 95 | 96 | --- 97 | ### Using @color[#DC143C](CRUDLFA+) 98 | - Register Post model with crudlfap . 99 | 100 | from crudlfap import crudlfap 101 | from .models import Post 102 | 103 | crudlfap.Router(model=Post).register() 104 | 105 | Note: 106 | - Create a file crudlfap.py inside your post application directory with this piece of code. 107 | 108 | --- 109 | ### Using @color[#DC143C](CRUDLFA+) 110 | - makemigrations for post model 111 | python manage.py makemigrations 112 | 113 | Note: 114 | - Now, We need to create post table using @color[#DC143C](makemigrations) command. 115 | - This will create a migration file inside post app migrations directory. 116 | 117 | --- 118 | ### Using @color[#DC143C](CRUDLFA+) 119 | - after that we need to apply migration by following command. 120 | python manage.py migrate 121 | 122 | --- 123 | ### @css[crudlfa-headline](Automatic model menus) 124 | ![Logo](assets/images/1.png) 125 | 126 | --- 127 | #### @color[#DC143C](Create Post with CRUDLFA+) 128 | ![Logo](assets/images/2.png) 129 | 130 | --- 131 | #### @color[#DC143C](Create Post pop-up with CRUDLFA+) 132 | ![Logo](assets/images/3.png) 133 | 134 | --- 135 | #### @color[#DC143C](List Posts with Automatic Object Level Menus) 136 | ![Logo](assets/images/4.png) 137 | 138 | --- 139 | #### @color[#DC143C](Update View) 140 | ![Logo](assets/images/5.png) 141 | 142 | --- 143 | #### @color[#DC143C](Delete View) 144 | ![Logo](assets/images/6.png) 145 | 146 | --- 147 | ### @color[#DC143C](Extend Object Icon) 148 | @css[byline](Change material icon ) 149 | 150 | ![Logo](assets/images/7.png) 151 | 152 | https://material.io/tools/icons/?style=baseline 153 | Note: 154 | - We can extend crudlfap feature, like we can change icon, namespace, we can override views etc. 155 | - We can change the icon by overriding "material_icon" inside router. you can get icon from https://material.io/tools/icons/?style=baseline 156 | - In next slide we shows the code as well to change the icon. 157 | 158 | 159 | ---?code=assets/sample/crudlfap.py&lang=python&title=@css[crudlfa-headline](Extending CRUDLFAP features) 160 | @[7-10](Extending @color[#DC143C](Router)) 161 | @[31-33](Change material icon) 162 | @[35-39](Extending List View) 163 | @[33-41](Extending Create View) 164 | @[13-18](Override ModelMixin Class) 165 | @[21-26](Override PostCreateView) 166 | 167 | 168 | Note: 169 | - We can extend crudlfap feature, like we can change icon, namespace, we can override views etc. 170 | - We can change the icon by overriding "material_icon" inside router. you can get icon from https://material.io/tools/icons/?style=baseline 171 | - We can override views like ListView we can set filters, search fields etc. 172 | - We can override CreateView to assign specific user or current user while creating post. 173 | 174 | 175 | ---?image=assets/images/gitpitch-audience.jpg 176 | # @color[#DC143C](Thank-You) 177 | -------------------------------------------------------------------------------- /PITCHME.yaml: -------------------------------------------------------------------------------- 1 | mathjax : TeX-MML-AM_HTMLorMML-full 2 | theme-override : assets/css/PITCHME.css 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/readthedocs/crudlfap.svg 2 | :target: https://crudlfap.readthedocs.io 3 | .. image:: https://yourlabs.io/oss/crudlfap/badges/master/build.svg 4 | :target: https://circleci.com/gh/yourlabs/crudlfap 5 | .. image:: https://img.shields.io/codecov/c/github/yourlabs/crudlfap/master.svg 6 | :target: https://codecov.io/gh/yourlabs/crudlfap 7 | .. image:: https://img.shields.io/npm/v/crudlfap.svg 8 | :target: https://www.npmjs.com/package/crudlfap 9 | .. image:: https://img.shields.io/pypi/v/crudlfap.svg 10 | :target: https://pypi.python.org/pypi/crudlfap 11 | 12 | Welcome to CRUDLFA+ for Django 3.0: because Django is FUN ! 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | CRUDLFA+ stands for Create Read Update Delete List Form Autocomplete and more. 16 | 17 | This plugin for Django makes a rich user interface from Django models, built 18 | with Material Components Web Ryzom Components, offering optionnal databinding 19 | with channels support. 20 | 21 | Demo: 22 | 23 | - last release: https://demo.crudlfap.ci.yourlabs.io/ 24 | - current master (might be down/broken etc): https://master.crudlfap.ci.yourlabs.io/ 25 | 26 | Try 27 | === 28 | 29 | This should start the example project from ``src/crudlfap_example`` where each 30 | documented example lives:: 31 | 32 | # This installs the repo in ./src/crudlfap and in your python user packages, i run this from ~ 33 | pip install --user -e git+https://github.com/yourlabs/crudlfap.git#egg=crudlfap[example] 34 | cd src/crudlfap 35 | 36 | ./manage.py migrate 37 | ./manage.py createsuperuser 38 | ./manage.py runserver 39 | 40 | Features 41 | ======== 42 | 43 | - DRY into ModelRouter for all views of a Model, 44 | - extensive CRUD views, actions, etc 45 | - Rich frontend interface out of the box, MDC/Ryzom/Unpoly 46 | 47 | Resources 48 | ========= 49 | 50 | - `Documentation 51 | `_ 52 | - `ChatRoom graciously hosted by 53 | `_ by `YourLabs Business Service 54 | `_ on `Mattermost 55 | `_ 56 | - `Mailing list graciously hosted 57 | `_ by `Google 58 | `_ 59 | - For **Security** issues, please contact yourlabs-security@googlegroups.com 60 | - `Git graciously hosted 61 | `_ by `YourLabs Business Service 62 | `_ with `GitLab 63 | `_ 64 | - `Package graciously hosted 65 | `_ by `PyPi 66 | `_, 67 | - `Continuous integration graciously hosted 68 | `_ by YourLabs Business Service 69 | - Browser test graciously hosted by `SauceLabs 70 | `_ 71 | -------------------------------------------------------------------------------- /assets/css/PITCHME.css: -------------------------------------------------------------------------------- 1 | 2 | .headline { 3 | color: blue; 4 | text-transform: uppercase; 5 | } 6 | 7 | .byline { 8 | color: gray; 9 | font-size: 0.9em; 10 | text-transform: lowercase; 11 | letter-spacing: 2px; 12 | } 13 | .crudlfa-headline { 14 | color: red; 15 | text-transform: uppercase; 16 | } -------------------------------------------------------------------------------- /assets/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/1.png -------------------------------------------------------------------------------- /assets/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/2.png -------------------------------------------------------------------------------- /assets/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/3.png -------------------------------------------------------------------------------- /assets/images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/4.png -------------------------------------------------------------------------------- /assets/images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/5.png -------------------------------------------------------------------------------- /assets/images/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/6.png -------------------------------------------------------------------------------- /assets/images/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/7.png -------------------------------------------------------------------------------- /assets/images/Banner_Logo_yourLabs_White_13_Copy_9_bkheoj.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/Banner_Logo_yourLabs_White_13_Copy_9_bkheoj.webp -------------------------------------------------------------------------------- /assets/images/CRUDFLAP_HOME.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/CRUDFLAP_HOME.png -------------------------------------------------------------------------------- /assets/images/Post_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/Post_create.png -------------------------------------------------------------------------------- /assets/images/YourLabs_Logo_Simple_transparent_wwbd35.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/YourLabs_Logo_Simple_transparent_wwbd35.webp -------------------------------------------------------------------------------- /assets/images/dir_str.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/dir_str.jpeg -------------------------------------------------------------------------------- /assets/images/dir_str_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/dir_str_1.png -------------------------------------------------------------------------------- /assets/images/gitpitch-audience.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/gitpitch-audience.jpg -------------------------------------------------------------------------------- /assets/images/in-60-seconds.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/in-60-seconds.jpg -------------------------------------------------------------------------------- /assets/images/post_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/post_list.png -------------------------------------------------------------------------------- /assets/images/post_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/post_menu.png -------------------------------------------------------------------------------- /assets/images/update_post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/assets/images/update_post.png -------------------------------------------------------------------------------- /assets/sample/crudlfap.py: -------------------------------------------------------------------------------- 1 | """Extending CRUDLFAP features.""" 2 | 3 | from crudlfap import crudlfap 4 | 5 | from .models import Post 6 | 7 | crudlfap.Router( 8 | Post, 9 | views=[] 10 | ).register() 11 | 12 | 13 | class PostMixin: 14 | """Create mixin.""" 15 | def get_exclude(self): 16 | if not self.request.user.is_staff: 17 | return ['owner'] 18 | return super().get_exclude() 19 | 20 | 21 | class PostCreateView(PostMixin, crudlfap.CreateView): 22 | """Override Post create view.""" 23 | def form_valid(self): 24 | """Assigned currnet user.""" 25 | self.form.instance.owner = self.request.user 26 | return super().form_valid() 27 | 28 | 29 | crudlfap.Router( 30 | Post, 31 | material_icon='forum', 32 | # https://material.io/tools/icons/?style=baseline 33 | namespace='posts', 34 | views=[ 35 | crudlfap.ListView.clone( 36 | filter_fields=['owner'], 37 | search_fields=['name', 'publish', 'owner'], 38 | ), 39 | PostCreateView, 40 | crudlfap.UpdateView, 41 | crudlfap.DeleteView, 42 | ] 43 | ).register() 44 | -------------------------------------------------------------------------------- /assets/sample/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | 6 | class Post(models.Model): 7 | """A Post model with name, description, publish and owner fields.""" 8 | name = models.CharField(max_length=100, verbose_name='title') 9 | description = models.TextField(verbose_name='Description') 10 | publish = models.DateTimeField(default=timezone.now) 11 | owner = models.ForeignKey(User, on_delete=models.CASCADE) 12 | 13 | def __str__(self): 14 | """Return string name.""" 15 | return self.name 16 | -------------------------------------------------------------------------------- /assets/sample/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mysite project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from crudlfap.settings import CRUDLFAP_APPS, CRUDLFAP_TEMPLATE_BACKEND 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = '-sfb3y%mgq##s+p%b=o48c(eye+4h05!0zs5lmka^c6+2m8iqc' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'post', 43 | ] + CRUDLFAP_APPS 44 | 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'mysite.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | '.....' 66 | ], 67 | }, 68 | }, 69 | CRUDLFAP_TEMPLATE_BACKEND, 70 | ] 71 | 72 | WSGI_APPLICATION = 'mysite.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 121 | 122 | STATIC_URL = '/static/' 123 | -------------------------------------------------------------------------------- /assets/sample/urls.py: -------------------------------------------------------------------------------- 1 | """mysite URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | from crudlfap import crudlfap 20 | 21 | urlpatterns = [ 22 | crudlfap.site.urlpattern, 23 | path('admin/', admin.site.urls), 24 | ] 25 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.sessions.backends.base import SessionBase 3 | from django.test.client import RequestFactory as drf 4 | 5 | 6 | class RequestFactory(drf): 7 | def __init__(self, user): 8 | self.user = user 9 | super().__init__() 10 | 11 | def generic(self, *args, **kwargs): 12 | request = super().generic(*args, **kwargs) 13 | request.session = SessionBase() 14 | request.user = self.user 15 | return request 16 | 17 | 18 | @pytest.fixture 19 | def srf(): 20 | from django.contrib.auth.models import AnonymousUser 21 | return RequestFactory(AnonymousUser()) 22 | -------------------------------------------------------------------------------- /docker-compose.persist.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | django: 4 | restart: always 5 | volumes: 6 | - ./data/media:/app/media 7 | - ./log:/app/log 8 | labels: 9 | - "io.yourlabs.compose.mkdir=./data/media,./log:1000:1000:0750" 10 | - "traefik.http.middlewares.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-redirect.redirectregex.regex=^https?://${HOST}/(.*)" 11 | - "traefik.http.middlewares.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-redirect.redirectregex.replacement=https://www.${HOST}/$${1}" 12 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.middlewares=${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-redirect" 13 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.entryPoints=websecure" 14 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.rule=host(`${HOST}`, `www.${HOST}`)" 15 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.tls=true" 16 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.tls.certResolver=leresolver" 17 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.service=${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl" 18 | - "traefik.http.services.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}-ssl.loadBalancer.server.port=8000" 19 | - "traefik.docker.network=web" 20 | networks: 21 | - web 22 | - default 23 | 24 | postgres: 25 | restart: always 26 | networks: 27 | - default 28 | volumes: 29 | - ./data/postgres:/var/lib/postgresql/data 30 | - ./dump:/dump 31 | labels: 32 | - "io.yourlabs.compose.mkdir=./dump,./data/postgres,./log/postgres:999:999:0700" 33 | 34 | networks: 35 | web: 36 | external: true 37 | -------------------------------------------------------------------------------- /docker-compose.traefik.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | django: 4 | labels: 5 | - "traefik.enable=true" 6 | - "traefik.docker.network=web" 7 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}.entryPoints=web" 8 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}.rule=host(`${HOST}`)" 9 | - "traefik.http.routers.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}.service=${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}" 10 | - "traefik.http.services.${CI_PROJECT_SLUG}-${CI_ENVIRONMENT_SLUG}.loadBalancer.server.port=8000" 11 | networks: 12 | - default 13 | - web 14 | 15 | networks: 16 | web: 17 | external: true 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | django: 4 | # DO NOT REMOVE 5 | # Ensures we keep access logs accross re-creates of the container 6 | # Use journalctl CONTAINER_NAME=production_backend_1 to see them 7 | logging: 8 | driver: journald 9 | 10 | build: 11 | dockerfile: Dockerfile 12 | context: . 13 | volumes: 14 | - /app/media 15 | - /app/log 16 | - /tmp 17 | environment: 18 | - HOST 19 | - PROTO 20 | - ADMINS 21 | - BASICAUTH_ENABLE 22 | - CI_ENVIRONMENT_NAME 23 | - CI_COMMIT_SHA 24 | - SENTRY_DSN 25 | - MEDIA_ROOT=/app/media 26 | - LOG_DIR=/app/log 27 | - IPFS_PATH=/app/.ipfs 28 | - EMAIL_HOST 29 | - EMAIL_HOST_USER 30 | - EMAIL_HOST_PASSWORD 31 | - EMAIL_USE_SSL 32 | - EMAIL_USE_TLS 33 | - EMAIL_PORT 34 | - DEFAULT_FROM_EMAIL 35 | - SECRET_KEY 36 | - DB_NAME=django 37 | - DB_USER=django 38 | - DB_PASS=django 39 | - DB_ENGINE=django.db.backends.postgresql 40 | - DB_HOST=postgres 41 | 42 | postgres: 43 | logging: 44 | driver: journald 45 | image: postgres:13 46 | environment: 47 | - POSTGRES_DB=django 48 | - POSTGRES_USER=django 49 | - POSTGRES_PASSWORD=django 50 | volumes: 51 | - /var/lib/postgresql/data 52 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = CRUDLFA 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | import django 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crudlfap_example.settings") 7 | os.environ.setdefault("DEBUG", "1") 8 | 9 | django.setup() 10 | 11 | # -*- coding: utf-8 -*- 12 | # 13 | # CRUDLFA+ documentation build configuration file, created by 14 | # sphinx-quickstart on Sat Nov 4 15:38:36 2017. 15 | # 16 | # This file is execfile()d with the current directory set to its 17 | # containing dir. 18 | # 19 | # Note that not all possible configuration values are present in this 20 | # autogenerated file. 21 | # 22 | # All configuration values have a default; values that are commented out 23 | # serve to show the default. 24 | 25 | # If extensions (or modules to document with autodoc) are in another directory, 26 | # add these directories to sys.path here. If the directory is relative to the 27 | # documentation root, use os.path.abspath to make it absolute, like shown here. 28 | # 29 | # import os 30 | # import sys 31 | # sys.path.insert(0, os.path.abspath('.')) 32 | 33 | 34 | # -- General configuration ------------------------------------------------ 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # 38 | # needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = ['sphinx.ext.autodoc', 44 | 'sphinx.ext.viewcode', 45 | 'sphinx.ext.githubpages'] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # The suffix(es) of source filenames. 51 | # You can specify multiple suffix as a list of string: 52 | # 53 | # source_suffix = ['.rst', '.md'] 54 | source_suffix = '.rst' 55 | 56 | # The master toctree document. 57 | master_doc = 'index' 58 | 59 | # General information about the project. 60 | project = 'CRUDLFA+' 61 | copyright = '2017, James Pic & Contributors' 62 | author = 'James Pic & Contributors' 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | version = '0.0' 70 | # The full version, including alpha/beta/rc tags. 71 | release = '0.0' 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = None 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | # This patterns also effect to html_static_path and html_extra_path 83 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # If true, `todo` and `todoList` produce output, else they produce nothing. 89 | todo_include_todos = False 90 | 91 | 92 | # -- Options for HTML output ---------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | # 97 | # html_theme = 'alabaster' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | # 103 | # html_theme_options = {} 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | html_static_path = ['_static'] 109 | 110 | # Custom sidebar templates, must be a dictionary that maps document names 111 | # to template names. 112 | # 113 | # This is required for the alabaster theme 114 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 115 | html_sidebars = { 116 | '**': [ 117 | 'about.html', 118 | 'navigation.html', 119 | 'relations.html', # needs 'show_related': True theme option to display 120 | 'searchbox.html', 121 | 'donate.html', 122 | ] 123 | } 124 | 125 | 126 | # -- Options for HTMLHelp output ------------------------------------------ 127 | 128 | # Output file base name for HTML help builder. 129 | htmlhelp_basename = 'CRUDLFAdoc' 130 | 131 | 132 | # -- Options for LaTeX output --------------------------------------------- 133 | 134 | latex_elements = { 135 | # The paper size ('letterpaper' or 'a4paper'). 136 | # 137 | # 'papersize': 'letterpaper', 138 | 139 | # The font size ('10pt', '11pt' or '12pt'). 140 | # 141 | # 'pointsize': '10pt', 142 | 143 | # Additional stuff for the LaTeX preamble. 144 | # 145 | # 'preamble': '', 146 | 147 | # Latex figure (float) alignment 148 | # 149 | # 'figure_align': 'htbp', 150 | } 151 | 152 | # Grouping the document tree into LaTeX files. List of tuples 153 | # (source start file, target name, title, 154 | # author, documentclass [howto, manual, or own class]). 155 | latex_documents = [ 156 | (master_doc, 'CRUDLFA.tex', 'CRUDLFA+ Documentation', 157 | 'James Pic \\& Contributors', 'manual'), 158 | ] 159 | 160 | 161 | # -- Options for manual page output --------------------------------------- 162 | 163 | # One entry per manual page. List of tuples 164 | # (source start file, name, description, authors, manual section). 165 | man_pages = [ 166 | (master_doc, 'crudlfa', 'CRUDLFA+ Documentation', 167 | [author], 1) 168 | ] 169 | 170 | 171 | # -- Options for Texinfo output ------------------------------------------- 172 | 173 | # Grouping the document tree into Texinfo files. List of tuples 174 | # (source start file, target name, title, author, 175 | # dir menu entry, description, category) 176 | texinfo_documents = [ 177 | (master_doc, 'CRUDLFA', 'CRUDLFA+ Documentation', 178 | author, 'CRUDLFA', 'One line description of project.', 179 | 'Miscellaneous'), 180 | ] 181 | 182 | 183 | 184 | # -- Options for Epub output ---------------------------------------------- 185 | 186 | # Bibliographic Dublin Core info. 187 | epub_title = project 188 | epub_author = author 189 | epub_publisher = author 190 | epub_copyright = copyright 191 | 192 | # The unique identifier of the text. This can be a ISBN number 193 | # or the project homepage. 194 | # 195 | # epub_identifier = '' 196 | 197 | # A unique identification for the text. 198 | # 199 | # epub_uid = '' 200 | 201 | # A list of files that should not be packed into the epub file. 202 | epub_exclude_files = ['search.html'] 203 | 204 | 205 | -------------------------------------------------------------------------------- /docs/crudlfap_auth/index.rst: -------------------------------------------------------------------------------- 1 | crudlfap_auth: crudlfap module for django.contrib.auth 2 | ====================================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | views 9 | -------------------------------------------------------------------------------- /docs/crudlfap_auth/views.rst: -------------------------------------------------------------------------------- 1 | Auth Views 2 | ~~~~~~~~~~ 3 | 4 | Source is located in the :py:class:`~crudlfap_auth.views`, which 5 | we'll describe here. 6 | 7 | .. automodule:: crudlfap_auth.views 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/factory.rst: -------------------------------------------------------------------------------- 1 | Factory DRY patterns 2 | ~~~~~~~~~~~~~~~~~~~~ 3 | 4 | .. automodule:: crudlfap.factory 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. CRUDLFA+ documentation master file, created by 2 | sphinx-quickstart on Sat Nov 4 15:38:36 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to CRUDLFA+'s documentation! 7 | ==================================== 8 | 9 | CRUDLFA+ stands for Create Read Update Delete List Form Autocomplete and more. 10 | 11 | This plugin for Django makes a rich user interface from Django models. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :caption: Contents: 16 | 17 | install 18 | tutorial 19 | route 20 | router 21 | settings 22 | views 23 | factory 24 | crudlfap_auth/index 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Install CRUDLFA+ module 2 | ~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | This section concerns 5 | This package can be installed from PyPi by running: 6 | 7 | Installing from PyPi 8 | -------------------- 9 | 10 | If you are just getting started with CRUDLFA+, it is recommended that you 11 | start by installing the latest version from the Python Package Index (PyPi_). 12 | To install CRUDLFA+ from PyPi using pip run the following command in your terminal. 13 | 14 | .. code-block:: bash 15 | 16 | pip install crudlfap 17 | 18 | If you are not in a virtualenv_, the above will fail if not executed as root, 19 | in this case use ``install --user``:: 20 | 21 | pip install --user crudlfap 22 | 23 | With development packages 24 | ------------------------- 25 | 26 | If you intend to run the ``crudlfap dev`` command, then you should have the 27 | development dependencies by adding ``[dev]``:: 28 | 29 | pip install (--user) crudlfap[dev] 30 | 31 | Then, you should see the example project running on port 8000 with command:: 32 | 33 | crudlfap dev 34 | 35 | Installing from GitHub 36 | ---------------------- 37 | 38 | You can install the latest current trunk of crudlfap directly from GitHub using pip_. 39 | 40 | .. code-block:: bash 41 | 42 | pip install --user -e git+git://github.com/yourlabs/crudlfap.git@master#egg=crudlfap[dev] 43 | 44 | .. warning:: ``[dev]``, ``--user``, ``@master`` are all optionnal above. 45 | 46 | Installing from source 47 | ---------------------- 48 | 49 | 1. Download a copy of the code from GitHub. You may need to install git_. 50 | 51 | .. code-block:: bash 52 | 53 | git clone https://github.com/yourlabs/crudlfap.git 54 | 55 | 2. Install the code you have just downloaded using pip, assuming your current 56 | working directory has not changed since the previous command it could be:: 57 | 58 | pip install -e ./crudlfap[dev] 59 | 60 | Move on to the :doc:`tutorial`. 61 | 62 | .. _git: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git 63 | .. _pip: https://pip.pypa.io/en/stable/installing/ 64 | .. _PyPi: https://pypi.python.org/pypi 65 | .. _virtualenv: https://virtualenv.pypa.io/ 66 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-js 3 | django 4 | # https://github.com/pallets/jinja/issues/1585 5 | MarkupSafe==2.0.1 6 | .[project] 7 | -------------------------------------------------------------------------------- /docs/route.rst: -------------------------------------------------------------------------------- 1 | Route class 2 | ~~~~~~~~~~~ 3 | 4 | .. automodule:: crudlfap.route 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/router.rst: -------------------------------------------------------------------------------- 1 | URLPatterns autogeneration mechanisms: Router 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | One of the key architectural concepts of CRUDLFA+ is the ability to tie a group 5 | of view with a model class to autogenerate urlpatterns. This chapter reviews 6 | the different mechanisms in place and how they are overridable. 7 | 8 | Source is located in the :py:class:`~crudlfap.router.Router`, which 9 | we'll describe here. 10 | 11 | .. automodule:: crudlfap.router 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/screenshots/add-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/docs/screenshots/add-modal.png -------------------------------------------------------------------------------- /docs/screenshots/detail-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/docs/screenshots/detail-template.png -------------------------------------------------------------------------------- /docs/screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/docs/screenshots/list.png -------------------------------------------------------------------------------- /docs/screenshots/update-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/docs/screenshots/update-form.png -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ~~~~~~~~ 3 | 4 | Project 5 | ======= 6 | 7 | .. automodule:: crudlfap.settings 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | CRUDLFA+ Tutorial 2 | ~~~~~~~~~~~~~~~~~ 3 | 4 | About document 5 | ============== 6 | 7 | This document attempts to teach the patterns you can use, and at the same time 8 | go through every feature. The document strives to teach CRUDLFA+ as efficiently 9 | as possible. If it becomes too long, we will see how we refactor the document, 10 | until then, it serves as main documentation. Please contribute any modification 11 | you feel this document needs to fit its purpose. 12 | 13 | About module 14 | ============ 15 | 16 | CRUDLFA+ strives to provide a modern UI for Django generic views out of the 17 | box, but all defaults should also be overiddable as conveniently as possible. 18 | It turns out that Django performs extremely well already, and pushing Django's 19 | philosophy such as DRY as far as possible works very well for me. 20 | 21 | Start a CRUDLFA+ project 22 | ======================== 23 | 24 | Make sure you install all project dependencies:: 25 | 26 | pip install crudlfap[project] 27 | 28 | And create a Django project:: 29 | 30 | django-admin startproject yourproject 31 | 32 | Copy this ``settings.py`` which provides working settings for CRUDLFA+ 33 | and allows to control settings with environment variables: 34 | 35 | .. literalinclude:: ../src/crudlfap_example/settings.py 36 | 37 | And this ``urls.py``: 38 | 39 | .. literalinclude:: ../src/crudlfap_example/urls.py 40 | 41 | You may also install manually, but the procedure might change over time. 42 | 43 | Create a ``crudlfap.py`` 44 | ======================== 45 | 46 | You need to create a Django app like with ``./manage.py startapp yourapp``. It 47 | creates a yourapp directory and you need to add yourapp to 48 | ``settings.INSTALLED_APPS``. 49 | 50 | Then, you can start a ``yourapp/crudlfap.py`` file, where you can define model 51 | CRUDs, hook into the rendering of CRUDLFA+ (ie. menus) and so on. 52 | 53 | Define a Router 54 | =============== 55 | 56 | Register a CRUD with default views using Router.register() 57 | ---------------------------------------------------------- 58 | 59 | Just add a ``crudlfap.py`` file in one of your installed apps, and the 60 | :py:class:`~crudlfap.apps.DefaultConfig` will autodiscover them, this example 61 | shows how to enable the default CRUD for a custom model: 62 | 63 | .. literalinclude:: ../src/crudlfap_example/artist/crudlfap.py 64 | 65 | In this case, the :py:class:`~crudlfap.router.Router` will get the views 66 | it should serve from the :py:data:`~crudlfap.settings.CRUDLFAP_VIEWS` 67 | setting. 68 | 69 | Custom view parameters with View.clone() 70 | ---------------------------------------- 71 | 72 | If you want to specify views in the router: 73 | 74 | .. literalinclude:: ../src/crudlfap_example/song/crudlfap.py 75 | 76 | Using the :py:meth:`~crudlfap.factory.Factory.clone()` classmethod will 77 | define a subclass on the fly with the given attributes. 78 | 79 | URLs 80 | ==== 81 | 82 | The easiest configuration is to generate patterns from the default registry:: 83 | 84 | from crudlfap import shortcuts as crudlfap 85 | 86 | urlpatterns = [ 87 | crudlfap.site.urlpattern 88 | ] 89 | 90 | Or, to sit in ``/admin``:: 91 | 92 | crudlfap.site.urlpath = 'admin' 93 | 94 | urlpatterns = [ 95 | crudlfap.site.urlpattern, 96 | # your patterns .. 97 | ] 98 | 99 | Changing home page 100 | ================== 101 | 102 | CRUDLFA+ so far relies on Jinja2 and provides a configuration 103 | where it finds templates in app_dir/jinja2. 104 | 105 | As such, a way to override the home page template is to create a directory 106 | "jinja2" in one of your apps - personnaly i add the project itself to 107 | INSTALLED_APPS, sorry if you have hard feelings about it but i love to do 108 | that, have a place to put project-specific stuff in general - and in the 109 | `jinja2` directory create a `crudlfap/home.html` file. 110 | 111 | You will also probably want to override `crudlfap/base.html`. But where it 112 | gets more interresting is when you replace the home view with your own. 113 | Example, still in urls.py:: 114 | 115 | from crudlfap import shortcuts as crudlfap 116 | from .views import Dashboard # your view 117 | 118 | crudlfap.site.title = 'Your Title' # used by base.html 119 | crudlfap.site.urlpath = 'admin' # example url prefix 120 | crudlfap.site.views['home'] = views.Dashboard 121 | 122 | urlpatterns = [ 123 | crudlfap.site.get_urlpattern(), 124 | ] 125 | 126 | So, there'd be other ways to acheive this but that's how i like to 127 | do it. 128 | 129 | Add menu item 130 | ============= 131 | 132 | You can hook into CRUDLFA+ menu rendering, ie.: 133 | 134 | .. literalinclude:: ../src/crudlfap_registration/crudlfap.py 135 | 136 | Create route from view 137 | ====================== 138 | 139 | The following example returns a :py:class:`~crudlfap.route.Route` as needed by 140 | :py:class:`~crudlfap.router.Router` and 141 | :py:class:`~crudlfap.registry.Registry`: 142 | 143 | .. code-block:: python 144 | 145 | Route.factory( 146 | LoginView, 147 | title=_('Login'), 148 | title_submit=_('Login'), 149 | title_menu=_('Login'), 150 | menus=['main'], 151 | redirect_authenticated_user=True, 152 | authenticate=False, 153 | icon='login', 154 | has_perm=lambda self: not self.request.user.is_authenticated, 155 | ) 156 | 157 | Useful to add external apps views to routers or site.views, prior to the menu 158 | hook feature. 159 | 160 | Create list actions 161 | =================== 162 | 163 | List actions, such as the delete action, can be implemented as such:: 164 | 165 | class DeployMixin(crudlfap.ActionMixin): 166 | style = '' 167 | icon = 'send' 168 | success_url_next = True 169 | color = 'green' 170 | form_class = forms.Form 171 | permission_shortcode = 'send' 172 | label = 'deploy' 173 | 174 | def get_success_url(self): 175 | return self.router['list'].reverse() 176 | 177 | def has_perm_object(self): 178 | return self.object.state == 'held' 179 | 180 | 181 | class TransactionDeployView(DeployMixin, crudlfap.ObjectFormView): 182 | def form_valid(self): 183 | self.object.state = 'deploy' 184 | self.object.save() 185 | return super().form_valid() 186 | 187 | 188 | class TransactionDeployMultipleView(DeployMixin, crudlfap.ObjectsFormView): 189 | def form_valid(self): 190 | self.object_list.filter(state='held').update(state='deploy') 191 | return super().form_valid() 192 | 193 | API 194 | === 195 | 196 | CRUDLFA+ now offers a REST API interface which is documented with swagger on 197 | ``/api``. 198 | 199 | Usage 200 | ----- 201 | 202 | It requires to set the ``Content-Type: application/json`` header. For example: 203 | 204 | .. code-block:: python 205 | 206 | import requests 207 | 208 | response = requests.get( 209 | f'http://localhost:8000/blockchain', 210 | headers={'Content-Type': 'application/json'} 211 | ) 212 | assert response.status_code == 200 213 | blockchains = response.json() 214 | 215 | POST requests require a CSRF token that you may obtain as a cookie with any GET 216 | request, but it is advised that you keep it up to date by using a request 217 | session. 218 | 219 | .. code-block:: python 220 | 221 | import requests 222 | session = requests.session() 223 | session.get('http://localhost:8000') 224 | response = session.post( 225 | f'{site}/auth/login/', 226 | dict( 227 | username='user', password='user' 228 | ), 229 | headers={'X-CSRFToken': session.cookies['csrftoken']}, 230 | allow_redirects=False, 231 | ) 232 | assert response.status_code == 302 233 | 234 | 235 | Then you can make more POST requests ie. to create an object: 236 | 237 | .. code-block:: python 238 | 239 | response = session.post( 240 | f'{site}/account/create', 241 | json=dict( 242 | blockchain=123, 243 | ), 244 | headers={ 245 | 'Content-Type': 'application/json', 246 | 'X-CSRFToken': session.cookies['csrftoken'], 247 | } 248 | ) 249 | assert response.status_code == 201 250 | 251 | In case of error, it will return a 4xx response with the error details in a 252 | JSON response. 253 | 254 | Customize the API 255 | ----------------- 256 | 257 | By default, views will call the router's 258 | :py:meth:`~crudlfap.router.Router.serialize(obj, fields)` method. You may override it, 259 | or just configure :py:attr:`~crudlfap.router.Router.json_fields` attribute. To 260 | serialize each field, it will try to find ``get_FIELDNAME_json()`` method, 261 | otherwise use the default 262 | :py:meth:`~crudlfap.router.Router.get_FIELDNAME_json(obj, field)` method which 263 | you might has well override. It will use the related router to serialize any 264 | related object. 265 | 266 | Then, you may override both of these at the view level if you want. 267 | -------------------------------------------------------------------------------- /docs/views.rst: -------------------------------------------------------------------------------- 1 | Views 2 | ~~~~~ 3 | 4 | Source is located in the :py:class:`~crudlfap.views.generic`, which 5 | we'll describe here. 6 | 7 | .. automodule:: crudlfap.views.generic 8 | :members: 9 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crudlfap_example.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /nightwatch/eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 8, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true, 7 | "browser": true, 8 | "es6": true, 9 | "jest": true 10 | } 11 | }, 12 | "globals": { 13 | "module": true, 14 | "exports": true, 15 | "process": true 16 | }, 17 | "rules": { 18 | "indent": [ 19 | "error", 20 | 2 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "single" 29 | ], 30 | "semi": [ 31 | "error", 32 | "always" 33 | ], 34 | "comma-dangle": [ 35 | "error", 36 | { 37 | "arrays": "never", 38 | "objects": "never", 39 | "imports": "never", 40 | "exports": "never", 41 | "functions": "ignore" 42 | } 43 | ], 44 | "object-curly-newline": [ 45 | "error", 46 | { 47 | "ObjectExpression": "always", 48 | "ObjectPattern": { 49 | "multiline": true 50 | } 51 | } 52 | ], 53 | "space-before-function-paren": [ 54 | "error", 55 | "always" 56 | ], 57 | "no-multiple-empty-lines": [ 58 | "error", 59 | { 60 | "max": 1, 61 | "maxEOF": 1 62 | } 63 | ], 64 | "no-trailing-spaces": "error", 65 | "eol-last": [ 66 | "error", 67 | "always" 68 | ], 69 | "no-cond-assign": [ 70 | "error", 71 | "always" 72 | ], 73 | "key-spacing": [ 74 | "error", 75 | { 76 | "beforeColon": false 77 | } 78 | ], 79 | "newline-before-return": "error", 80 | "no-console": "off" 81 | } 82 | } -------------------------------------------------------------------------------- /nightwatch/globals-driver.js: -------------------------------------------------------------------------------- 1 | const chromedriver = require('chromedriver'); 2 | module.exports = { 3 | before: (done) => { 4 | chromedriver.start(); 5 | 6 | done(); 7 | }, 8 | 9 | after: (done) => { 10 | chromedriver.stop(); 11 | 12 | done(); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /nightwatch/globals.js: -------------------------------------------------------------------------------- 1 | /* globals process */ 2 | const sauceConnectLauncher = require('sauce-connect-launcher'); 3 | 4 | let sauceConnectTunnel; 5 | 6 | module.exports = { 7 | before: (done) => { 8 | sauceConnectLauncher({ 9 | username: process.env.SAUCELABS_USERNAME, 10 | accessKey: process.env.SAUCELABS_TOKEN 11 | }, function (err, sauceConnectProcess) { 12 | if (err) { 13 | console.error('Sauce Connect Error : ', err.message); 14 | process.exit(1); 15 | } 16 | console.log('Sauce Connect ready'); 17 | sauceConnectTunnel = sauceConnectProcess; 18 | done(); 19 | }); 20 | }, 21 | after: (done) => { 22 | sauceConnectTunnel.close(function () { 23 | console.log('Closed Sauce Connect process'); 24 | done(); 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /nightwatch/nightwatch-driver.json: -------------------------------------------------------------------------------- 1 | { 2 | "src_folders": [ 3 | "dist/tests" 4 | ], 5 | "output_folder": "reports", 6 | "custom_commands_path": "", 7 | "custom_assertions_path": "", 8 | "page_objects_path": "", 9 | "globals_path": "./globals-driver.js", 10 | "selenium": { 11 | "start_process": false 12 | }, 13 | "test_settings": { 14 | "default": { 15 | "selenium_port": 9515, 16 | "selenium_host": "localhost", 17 | "default_path_prefix": "", 18 | "desiredCapabilities": { 19 | "browserName": "chrome", 20 | "chromeOptions": { 21 | "args": [ 22 | "--no-sandbox" 23 | ] 24 | }, 25 | "acceptSslCerts": true 26 | } 27 | }, 28 | "chrome": { 29 | "desiredCapabilities": { 30 | "browserName": "chrome" 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /nightwatch/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | const config = { // we use a nightwatch.conf.js file so we can include comments and helper functions 2 | 'src_folders': [ 3 | 'dist/tests' 4 | ], 5 | 'output_folder': 'reports', 6 | 'custom_commands_path': '', 7 | 'custom_assertions_path': '', 8 | 'page_objects_path': '', 9 | 'globals_path': './globals.js', 10 | 'selenium': { 11 | 'start_process': false, 12 | 'server_path': '', 13 | 'log_path': '', 14 | 'host': '127.0.0.1', 15 | 'port': 4444 16 | }, 17 | 'test_workers': { 18 | 'enabled': true, 'workers': 'auto' 19 | }, // perform tests in parallel where possible 20 | 'test_settings': { 21 | 'default': { 22 | 'launch_url': 'http://ondemand.saucelabs.com:80', 23 | 'selenium_port': 80, 24 | 'selenium_host': 'ondemand.saucelabs.com', 25 | 'silent': true, 26 | 'screenshots': { 27 | 'enabled': false, 28 | 'path': '' 29 | }, 30 | 'username': process.env.SAUCELABS_USERNAME, 31 | 'access_key': process.env.SAUCELABS_TOKEN, 32 | 'skip_testcases_on_fail': false, 33 | 'desiredCapabilities': { 34 | 'browserName': 'chrome', 35 | 'version': 'latest', 36 | 'chromeOptions': { 37 | 'args': [ 38 | '--no-sandbox' 39 | ] 40 | }, 41 | 'acceptSslCerts': true 42 | } 43 | }, 44 | 'chrome': { 45 | 'desiredCapabilities': { 46 | 'browserName': 'chrome', 47 | 'version': 'latest', 48 | 'javascriptEnabled': true, 49 | 'acceptSslCerts': true 50 | } 51 | }, 52 | 'internet_explorer': { 53 | 'desiredCapabilities': { 54 | 'browserName': 'internet explorer', 55 | 'version': 'latest', 56 | 'javascriptEnabled': true, 57 | 'acceptSslCerts': true 58 | } 59 | }, 60 | 'firefox': { 61 | 'desiredCapabilities': { 62 | 'browserName': 'firefox', 63 | 'version': 'latest', 64 | 'javascriptEnabled': true, 65 | 'acceptSslCerts': true 66 | } 67 | } 68 | } 69 | }; 70 | module.exports = config; 71 | -------------------------------------------------------------------------------- /nightwatch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nightwatch-typescript-starter", 3 | "version": "1.0.0", 4 | "description": "Simple nightwatch.js Typescript starter", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "./node_modules/.bin/tsc", 8 | "pretest": "yarn install && yarn run build", 9 | "lint": "eslint -c ./eslintrc.json .", 10 | "test": "./node_modules/.bin/nightwatch" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/anjmao/nightwatch-typescript-starter.git" 15 | }, 16 | "author": "Andzej Maciusovic", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/anjmao/nightwatch-typescript-starter/issues" 20 | }, 21 | "homepage": "https://github.com/anjmao/nightwatch-typescript-starter#readme", 22 | "devDependencies": { 23 | "@types/nightwatch": "^0.9.8", 24 | "@types/node": "^8.10.10", 25 | "chromedriver": "^2.33.2", 26 | "nightwatch": "^0.9.21", 27 | "typescript": "^2.8.3", 28 | "eslint": "^4.19.1", 29 | "sauce-connect-launcher": "^1.2.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nightwatch/shared/CONSTANTS.ts: -------------------------------------------------------------------------------- 1 | export const CONSTANTS = { 2 | BASE_URL: 'http://localhost:8000', 3 | LOGIN_URL: 'http://localhost:8000/login', 4 | LOGOUT_URL: 'http://localhost:8000/logout', 5 | GROUP: { 6 | BASE_URL: 'http://localhost:8000/group', 7 | CREATE: 'http://localhost:8000/group/create', 8 | INPUT: 'Test Group for sample', 9 | INPUT2: 'Test Group for sample with permission', 10 | EDIT_INPUT: 'Updated Group', 11 | }, 12 | ARTIST: { 13 | BASE_URL: 'http://localhost:8000/artist', 14 | CREATE: 'http://localhost:8000/artist/create', 15 | INPUT: 'Test artist for sample', 16 | INPUT2: 'Test artist for sample with permission', 17 | EDIT_INPUT: 'Updated artist', 18 | }, 19 | SONGS: { 20 | BASE_URL: 'http://localhost:8000/song', 21 | CREATE: 'http://localhost:8000/song/create', 22 | INPUT: 'Test song for sample', 23 | INPUT2: 'Test song for sample 2', 24 | INPUT_DURATION: 180, 25 | EDIT_INPUT: 'Updated Song', 26 | }, 27 | SONGRATING: { 28 | BASE_URL: 'http://localhost:8000/songrating', 29 | CREATE: 'http://localhost:8000/songrating/create', 30 | INPUT: '2', 31 | INPUT2: '3', 32 | EDIT_INPUT: '5', 33 | }, 34 | USER: { 35 | BASE_URL: 'http://localhost:8000/user', 36 | CREATE: 'http://localhost:8000/user/create', 37 | INVALID_USER: 'test @123', 38 | EXISTING_USER: 'dev', 39 | INPUT_USER: 'test@123', 40 | INPUT_EMAIL: 'test@123.com', 41 | INPUT2: 'Test User for sample with groups', 42 | EDIT_INPUT: 'Updated@123', 43 | EDIT_EMAIL: 'updated@123.com', 44 | 45 | }, 46 | POST: { 47 | BASE_URL: 'http://localhost:8000/post', 48 | CREATE: 'http://localhost:8000/post/create', 49 | INPUT_TITLE: 'Test Post', 50 | INPUT_TITLE2: 'Test Post 2', 51 | EDIT_INPUT: 'Update post' 52 | }, 53 | WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT: 15000, 54 | PAUSE_TIMEOUT: 10000, 55 | USER_CREDENTIALS: { 56 | RIGHT: { 57 | USERNAME: 'dev', 58 | PASSWORD: 'dev' 59 | }, 60 | WRONG: { 61 | USERNAME: 'dev1', 62 | PASSWORD: 'dev1' 63 | } 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /nightwatch/tests/artist/validation.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../../shared/CONSTANTS'; 3 | import { CommonFunction } from '../../shared/commonFunction'; 4 | 5 | module.exports = { 6 | 'Artist : create artist validation': async (browser: NightwatchBrowser) => { 7 | await CommonFunction.loginByDev(browser); 8 | browser 9 | // after login go to song create page direct 10 | .url(CONSTANTS.ARTIST.CREATE) 11 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 12 | 13 | // name input 14 | .assert.containsText('#id_name_container > label', 'Name') 15 | .assert.visible('input[id=id_name]') 16 | 17 | .assert.visible('#form-object-artist > div.modal-footer > button[type=submit]') 18 | .click('#form-object-artist > div.modal-footer > button[type=submit]') 19 | 20 | .pause(CONSTANTS.PAUSE_TIMEOUT) 21 | 22 | .assert.visible('#id_name_container > div > small') 23 | .assert.containsText('#id_name_container > div > small', 'This field is required.') 24 | 25 | .end(); 26 | } 27 | }; -------------------------------------------------------------------------------- /nightwatch/tests/group/validation.ts: -------------------------------------------------------------------------------- 1 | 2 | import { NightwatchBrowser } from 'nightwatch'; 3 | import { CONSTANTS } from '../../shared/CONSTANTS'; 4 | import { CommonFunction } from '../../shared/commonFunction'; 5 | 6 | module.exports = { 7 | 'Group : create group validation': async (browser: NightwatchBrowser) => { 8 | await CommonFunction.loginByDev(browser); 9 | browser 10 | // after login go to group create page direct 11 | .url(CONSTANTS.GROUP.CREATE) 12 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 13 | // name input 14 | .assert.containsText('#id_name_container > label', 'Name') 15 | .assert.visible('input[id=id_name]') 16 | // submit button 17 | .assert.visible('#form-object-group .modal-footer button[type=submit]') 18 | .click('#form-object-group .modal-footer button[type=submit]') 19 | .pause(CONSTANTS.PAUSE_TIMEOUT) 20 | 21 | .assert.visible('#id_name_container > div > small') 22 | .assert.containsText('#id_name_container > div > small', 'This field is required.') 23 | 24 | .end(); 25 | } 26 | }; -------------------------------------------------------------------------------- /nightwatch/tests/login.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../shared/CONSTANTS'; 3 | 4 | module.exports = { 5 | 'Login : submit with wrong credentials': (browser: NightwatchBrowser) => { 6 | browser 7 | .url(CONSTANTS.LOGIN_URL) 8 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 9 | 10 | .assert.visible('input[id=id_username]') 11 | .setValue('input[id=id_username]', CONSTANTS.USER_CREDENTIALS.WRONG.USERNAME) 12 | 13 | .assert.visible('input[id=id_password]') 14 | .setValue('input[id=id_password]', CONSTANTS.USER_CREDENTIALS.WRONG.PASSWORD) 15 | 16 | .assert.visible('button[type=submit]') 17 | .click('button[type=submit]') 18 | .pause(CONSTANTS.PAUSE_TIMEOUT) 19 | 20 | // after login 21 | .assert.containsText('small[class=error]', 'Please enter a correct username and password. Note that both fields may be case-sensitive.') 22 | .end(); 23 | }, 24 | 25 | 'Login : submit with correct credentials': (browser: NightwatchBrowser) => { 26 | browser 27 | .url(CONSTANTS.LOGIN_URL) 28 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 29 | 30 | .assert.visible('input[id=id_username]') 31 | .setValue('input[id=id_username]', CONSTANTS.USER_CREDENTIALS.RIGHT.USERNAME) 32 | 33 | .assert.visible('input[id=id_password]') 34 | .setValue('input[id=id_password]', CONSTANTS.USER_CREDENTIALS.RIGHT.PASSWORD) 35 | 36 | .assert.visible('button[type=submit]') 37 | .click('button[type=submit]') 38 | 39 | // after login 40 | .waitForElementVisible('.container .orange-text', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 41 | // .assert.title('Home - CRUDLFA+') 42 | .assert.visible('a[class=waves-effect]') 43 | .end(); 44 | } 45 | }; -------------------------------------------------------------------------------- /nightwatch/tests/logout.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../shared/CONSTANTS'; 3 | 4 | module.exports = { 5 | before: function (browser) { 6 | // console.log("Before working!"); 7 | // login user with correct crednetials 8 | browser 9 | .url(CONSTANTS.LOGIN_URL) 10 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 11 | 12 | .assert.visible('input[id=id_username]') 13 | .setValue('input[id=id_username]', CONSTANTS.USER_CREDENTIALS.RIGHT.USERNAME) 14 | 15 | .assert.visible('input[id=id_password]') 16 | .setValue('input[id=id_password]', CONSTANTS.USER_CREDENTIALS.RIGHT.PASSWORD) 17 | 18 | .assert.visible('button[type=submit]') 19 | .click('button[type=submit]') 20 | 21 | .waitForElementVisible('ul[class="right"] li a[href="/logout"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 22 | }, 23 | 'Logout': (browser: NightwatchBrowser) => { 24 | browser 25 | // after login 26 | .click('a[href="/logout"]') 27 | 28 | // on logout page 29 | .waitForElementVisible('ul[class="right"] li a[href="/login"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 30 | 31 | .assert.urlEquals(CONSTANTS.LOGOUT_URL, 'Logout url is the correct') 32 | 33 | .end(); 34 | } 35 | 36 | }; -------------------------------------------------------------------------------- /nightwatch/tests/menu.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from './../shared/CONSTANTS'; 3 | module.exports = { 4 | 'Before Login: Menu should visible when click on the menu icon': (browser: NightwatchBrowser) => { 5 | browser 6 | .url(CONSTANTS.LOGIN_URL) 7 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 8 | 9 | .assert.visible('span[class="sidenav-trigger"]') 10 | .element('id', 'slide-out', function (result) { 11 | browser.assert.equal(result.status, false) 12 | }) 13 | .click('span[class="sidenav-trigger"]') 14 | .waitForElementVisible('ul[id="slide-out"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 15 | .isVisible('ul[id="slide-out"]', (result) => { 16 | browser.assert.equal(result.value, true) 17 | }) 18 | .click('ul#slide-out li.no-padding a[href="/"]') 19 | .end(); 20 | }, 21 | 22 | 'Before Login: Menu should contains "login" option': (browser: NightwatchBrowser) => { 23 | browser 24 | .url(CONSTANTS.LOGIN_URL) 25 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 26 | .waitForElementVisible('span[class="sidenav-trigger"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 27 | 28 | .assert.visible('span[class="sidenav-trigger"]') 29 | .click('span[class="sidenav-trigger"]') 30 | .waitForElementVisible('ul#slide-out li.no-padding a[href="/login"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 31 | 32 | .elements('css selector', 'ul#slide-out li.no-padding a[href="/login"]', (result) => { 33 | browser.assert.equal(result.value.length, 1) 34 | }) 35 | .end(); 36 | }, 37 | 38 | 'After Login: Menu should contains the "logout" option': (browser: NightwatchBrowser) => { 39 | browser 40 | .url(CONSTANTS.LOGIN_URL) 41 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 42 | 43 | .elements('css selector', 'ul#slide-out li.no-padding a[href="/login"]', (result) => { 44 | browser.assert.equal(result.value.length, 1) 45 | }) 46 | 47 | .click('ul#slide-out li.no-padding a[href="/login"]') 48 | 49 | .waitForElementVisible('input[id=id_username]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 50 | .assert.visible('input[id=id_username]') 51 | .setValue('input[id=id_username]', CONSTANTS.USER_CREDENTIALS.RIGHT.USERNAME) 52 | .assert.visible('input[id=id_password]') 53 | .setValue('input[id=id_password]', CONSTANTS.USER_CREDENTIALS.RIGHT.PASSWORD) 54 | 55 | .assert.visible('button[type=submit]') 56 | .click('button[type=submit]') 57 | .waitForElementVisible('ul[class="right"] li a[href="/logout"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 58 | 59 | .assert.visible('span[class="sidenav-trigger"]') 60 | .click('span[class="sidenav-trigger"]') 61 | .waitForElementVisible('ul[id="slide-out"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 62 | .assert.visible('ul[id="slide-out"]') 63 | 64 | .elements('css selector', '#slide-out > li:nth-child(4) > a[href="/logout"]', (result) => { 65 | browser.assert.equal(result.value.length, 1) 66 | }) 67 | .end(); 68 | }, 69 | 'After Logout: Menu should contains "login" option': (browser: NightwatchBrowser) => { 70 | browser 71 | .url(CONSTANTS.BASE_URL) 72 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 73 | .assert.visible('span[class="sidenav-trigger"]') 74 | .element('id', 'slide-out', function (result) { 75 | browser.assert.equal(result.status, false) 76 | }) 77 | .click('span[class="sidenav-trigger"]') 78 | .waitForElementVisible('ul[class="right"] li a[href="/login"]', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 79 | .isVisible('ul[id="slide-out"]', (result) => { 80 | browser.assert.equal(result.value, true) 81 | }) 82 | 83 | .elements('css selector', 'ul#slide-out li.no-padding a[href="/login"]', (result) => { 84 | browser.assert.equal(result.value.length, 1) 85 | }) 86 | .end(); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /nightwatch/tests/post/validation.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../../shared/CONSTANTS'; 3 | import { CommonFunction } from '../../shared/commonFunction'; 4 | 5 | module.exports = { 6 | 'Post : create post validation': async (browser: NightwatchBrowser) => { 7 | await CommonFunction.loginByDev(browser); 8 | browser 9 | // after login go to post create page direct 10 | .url(CONSTANTS.POST.CREATE) 11 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 12 | // name input 13 | .assert.containsText('#id_name_container > label', 'Title') 14 | .assert.visible('input[id=id_name]') 15 | // publish input 16 | .assert.containsText('#id_publish_container > label', 'Publish') 17 | .assert.visible('input[id=id_publish]') 18 | .clearValue('input[id=id_publish]') 19 | .pause(CONSTANTS.PAUSE_TIMEOUT) 20 | 21 | // owner selection 22 | .assert.containsText('#id_owner_container > label', 'Owner') 23 | .assert.visible('#id_owner_container > div.select-wrapper > input') 24 | 25 | // submit button 26 | .assert.visible('#form-object-post .modal-footer button[type=submit]') 27 | .click('#form-object-post .modal-footer button[type=submit]') 28 | .pause(CONSTANTS.PAUSE_TIMEOUT) 29 | 30 | .assert.visible('#id_name_container > div > small') 31 | .assert.containsText('#id_name_container > div > small', 'This field is required.') 32 | 33 | .assert.visible('#id_publish_container > div > small') 34 | .assert.containsText('#id_publish_container > div > small', 'This field is required.') 35 | 36 | .assert.visible('#id_owner_container > div > small') 37 | .assert.containsText('#id_owner_container > div > small', 'This field is required.') 38 | 39 | .end(); 40 | } 41 | }; -------------------------------------------------------------------------------- /nightwatch/tests/songRating/validation.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../../shared/CONSTANTS'; 3 | import { CommonFunction } from '../../shared/commonFunction'; 4 | 5 | module.exports = { 6 | 'SongRating : create song-rating validation': async (browser: NightwatchBrowser) => { 7 | await CommonFunction.loginByDev(browser); 8 | browser 9 | // after login go to song-rating create page direct 10 | .url(CONSTANTS.SONGRATING.CREATE) 11 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 12 | // song selection 13 | .assert.containsText('#id_song_container > label', 'Song') 14 | .assert.visible('#id_song_container > div.select-wrapper > input') 15 | 16 | // name input 17 | .assert.containsText('#id_rating_container > label', 'Rating') 18 | .assert.visible('input[id=id_rating]') 19 | 20 | // submit button 21 | .assert.visible('#form-object-songrating > div.modal-footer > button[type=submit]') 22 | .click('#form-object-songrating > div.modal-footer > button[type=submit]') 23 | .pause(CONSTANTS.PAUSE_TIMEOUT) 24 | 25 | .assert.visible('#id_song_container > div > small') 26 | .assert.containsText('#id_song_container > div > small', 'This field is required.') 27 | 28 | .assert.visible('#id_rating_container > div > small') 29 | .assert.containsText('#id_rating_container > div > small', 'This field is required.') 30 | 31 | .end(); 32 | } 33 | }; -------------------------------------------------------------------------------- /nightwatch/tests/songs/validation.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../../shared/CONSTANTS'; 3 | import { CommonFunction } from '../../shared/commonFunction'; 4 | 5 | module.exports = { 6 | 'Song : create song validation': async (browser: NightwatchBrowser) => { 7 | await CommonFunction.loginByDev(browser); 8 | browser 9 | // after login go to song create page direct 10 | .url(CONSTANTS.SONGS.CREATE) 11 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 12 | // artist selection 13 | .assert.containsText('#id_artist_container > label', 'Artist') 14 | .assert.visible('#id_artist_container > div.select-wrapper > input') 15 | 16 | // name input 17 | .assert.containsText('#id_name_container > label', 'Title') 18 | .assert.visible('input[id=id_name]') 19 | 20 | // duration input 21 | .assert.containsText('#id_duration_container > label', 'Duration') 22 | .assert.visible('input[id=id_duration]') 23 | .clearValue('input[id=id_duration]') 24 | .pause(CONSTANTS.PAUSE_TIMEOUT) 25 | 26 | // owner selection 27 | .assert.containsText('#id_owner_container > label', 'Owner') 28 | .assert.visible('#id_owner_container > div.select-wrapper > input') 29 | 30 | // submit button 31 | .assert.visible('#form-object-song > div.modal-footer > button[type=submit]') 32 | .click('#form-object-song > div.modal-footer > button[type=submit]') 33 | .pause(CONSTANTS.PAUSE_TIMEOUT) 34 | 35 | .assert.visible('#id_artist_container > div > small') 36 | .assert.containsText('#id_artist_container > div > small', 'This field is required.') 37 | 38 | .assert.visible('#id_name_container > div > small') 39 | .assert.containsText('#id_name_container > div > small', 'This field is required.') 40 | 41 | .assert.visible('#id_duration_container > div > small') 42 | .assert.containsText('#id_duration_container > div > small', 'This field is required.') 43 | 44 | .assert.visible('#id_owner_container > div > small') 45 | .assert.containsText('#id_owner_container > div > small', 'This field is required.') 46 | 47 | .end(); 48 | } 49 | }; -------------------------------------------------------------------------------- /nightwatch/tests/user/validation.ts: -------------------------------------------------------------------------------- 1 | import { NightwatchBrowser } from 'nightwatch'; 2 | import { CONSTANTS } from '../../shared/CONSTANTS'; 3 | import { CommonFunction } from '../../shared/commonFunction'; 4 | 5 | module.exports = { 6 | 'User : create user validation': async (browser: NightwatchBrowser) => { 7 | await CommonFunction.loginByDev(browser); 8 | browser 9 | // after login go to user create page direct 10 | .url(CONSTANTS.USER.CREATE) 11 | .waitForElementVisible('body', CONSTANTS.WAIT_FOR_ELEMENT_VISIBLE_TIMEOUT) 12 | 13 | // blank validation 14 | // username input 15 | .assert.containsText('#id_username_container > label', 'Username') 16 | .assert.visible('input[id=id_username]') 17 | 18 | // email input 19 | .assert.containsText('#id_email_container > label', 'Email address') 20 | .assert.visible('input[id=id_email]') 21 | 22 | // Groups input 23 | .assert.containsText('#id_groups_container > label', 'Groups') 24 | .assert.visible('#id_groups_container > div.select-wrapper > input') 25 | 26 | // staff selection 27 | .assert.containsText('#id_is_staff_container > label > span', 'Staff status') 28 | // .assert.visible('#id_is_staff_container > label > input') 29 | 30 | // Superuser status 31 | .assert.containsText('#id_is_superuser_container > label > span', 'Superuser status') 32 | // .assert.visible('#id_is_superuser_container > label > input') 33 | 34 | // submit button 35 | .assert.visible('#form-object-user > div.modal-footer > button[type=submit]') 36 | .click('#form-object-user > div.modal-footer > button[type=submit]') 37 | .pause(CONSTANTS.PAUSE_TIMEOUT) 38 | 39 | .assert.visible('#id_username_container > div.errors > small') 40 | .assert.containsText('#id_username_container > div.errors > small', 'This field is required.') 41 | .pause(CONSTANTS.PAUSE_TIMEOUT) 42 | 43 | // invalid validation 44 | .pause(CONSTANTS.PAUSE_TIMEOUT) 45 | .assert.visible('input[id=id_username]') 46 | .setValue('input[id=id_username]', CONSTANTS.USER.INVALID_USER) 47 | 48 | // submit button 49 | .assert.visible('#form-object-user > div.modal-footer > button[type=submit]') 50 | .click('#form-object-user > div.modal-footer > button[type=submit]') 51 | .pause(CONSTANTS.PAUSE_TIMEOUT) 52 | 53 | .pause(CONSTANTS.PAUSE_TIMEOUT) 54 | .assert.visible('#id_username_container> div.errors > small') 55 | .assert.containsText('#id_username_container > div.errors > small', 'Enter a valid username. This value may contain only letters, numbers, and @/./+/-/_ characters.') 56 | 57 | // duplicate validation 58 | .pause(CONSTANTS.PAUSE_TIMEOUT) 59 | .assert.visible('input[id=id_username]') 60 | .clearValue('input[id=id_username]') 61 | .setValue('input[id=id_username]', CONSTANTS.USER.EXISTING_USER) 62 | 63 | .pause(CONSTANTS.PAUSE_TIMEOUT) 64 | // submit button 65 | .assert.visible('#form-object-user > div.modal-footer > button[type=submit]') 66 | .click('#form-object-user > div.modal-footer > button[type=submit]') 67 | .pause(CONSTANTS.PAUSE_TIMEOUT) 68 | 69 | .assert.visible('#id_username_container > div.errors > small') 70 | .assert.containsText('#id_username_container > div.errors > small', 'A user with that username already exists.') 71 | 72 | .end(); 73 | } 74 | }; -------------------------------------------------------------------------------- /nightwatch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es6", 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "sourceMap": true 8 | }, 9 | "include": [ 10 | "tests/**/*.ts" 11 | ], 12 | "exclude": [ 13 | "node_modules" 14 | ] 15 | } -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = crudlfap_example.settings 3 | CHANNELS_ENABLE = true 4 | django_debug_mode = keep 5 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: [dev] 16 | - requirements: docs/requirements.txt 17 | 18 | formats: 19 | - pdf 20 | - epub 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import find_packages, setup 5 | from setuptools.command.install import install 6 | 7 | setup( 8 | name='crudlfap', 9 | setup_requires='setupmeta', 10 | versioning='dev', 11 | description='Rich frontend for generic views with Django', 12 | author='James Pic', 13 | author_email='jamespic@gmail.com', 14 | url='https://yourlabs.io/oss/crudlfap', 15 | packages=find_packages('src'), 16 | package_dir={'': 'src'}, 17 | include_package_data=True, 18 | keywords='django crud', 19 | install_requires=[ 20 | 'ryzom>=0.7.11,<0.8', 21 | 'django>=3.1,<3.2', 22 | 'django-tables2', 23 | 'django-filter', 24 | 'timeago', 25 | 'lookupy-unmanaged', 26 | 'libsass', 27 | ], 28 | tests_require=['tox'], 29 | extras_require=dict( 30 | project=[ 31 | 'django-debug-toolbar', 32 | 'django-extensions', 33 | 'django-registration', 34 | 'dj-static', 35 | ], 36 | ), 37 | classifiers=[ 38 | 'Development Status :: 1 - Planning', 39 | 'Environment :: Web Environment', 40 | 'Framework :: Django', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: MIT License', 43 | 'Operating System :: OS Independent', 44 | 'Programming Language :: Python', 45 | 'Programming Language :: Python :: 2', 46 | 'Programming Language :: Python :: 3', 47 | 'Topic :: Internet :: WWW/HTTP', 48 | 'Topic :: Software Development :: Libraries :: Python Modules', 49 | ], 50 | python_requires='>=3', 51 | ) 52 | -------------------------------------------------------------------------------- /src/crudlfap/__init__.py: -------------------------------------------------------------------------------- 1 | """Sets the default appconfig to :py:cls:`~crudlfap.apps.DefaultConfig`.""" 2 | from django.utils.module_loading import autodiscover_modules 3 | 4 | 5 | def autodiscover(): 6 | autodiscover_modules('crudlfap') 7 | 8 | 9 | default_app_config = 'crudlfap.apps.DefaultConfig' 10 | -------------------------------------------------------------------------------- /src/crudlfap/apps.py: -------------------------------------------------------------------------------- 1 | """Django AppConfig for the crudlfap module.""" 2 | 3 | from django import apps 4 | from django.conf import settings 5 | 6 | 7 | def _installed(*apps): 8 | for app in apps: 9 | if app not in settings.INSTALLED_APPS: 10 | return False 11 | return True 12 | 13 | 14 | class DefaultConfig(apps.AppConfig): 15 | """Default AppConfig.""" 16 | 17 | name = 'crudlfap' 18 | 19 | def ready(self): 20 | super().ready() 21 | self.module.autodiscover() 22 | -------------------------------------------------------------------------------- /src/crudlfap/conf.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from typing import Any, Dict, List, Optional 3 | 4 | SOURCEREF = List[Dict[str, Optional[Dict[str, Any]]]] 5 | 6 | __all__ = ( 7 | 'add_optional_dep', 8 | 'install_optional', 9 | ) 10 | 11 | 12 | if 'ModuleNotFoundError' not in globals(): 13 | ModuleNotFoundError = ImportError 14 | 15 | 16 | def module_installed(module: str) -> bool: 17 | """ 18 | Determines if a given module string can be resolved 19 | 20 | Determine if the module referenced by string, can be imported by trying 21 | an import in two ways: 22 | 23 | - direct import of the module 24 | - import of the module minus the last part, then see if the last part is 25 | an attribute of the module. 26 | 27 | Parts of course, are separated by dots. 28 | 29 | :param module: module reference 30 | :return: True if importable, False otherwise 31 | """ 32 | have_module = False 33 | try: 34 | importlib.__import__(module) 35 | except ModuleNotFoundError: 36 | mod_path, dot, cls = module.rpartition('.') 37 | if not mod_path: 38 | return False 39 | try: 40 | mod = importlib.import_module(mod_path) 41 | except ModuleNotFoundError: 42 | return False 43 | else: 44 | if hasattr(mod, cls): 45 | have_module = True 46 | else: 47 | have_module = True 48 | 49 | return have_module 50 | 51 | 52 | def add_optional_dep(module: str, to: List[str], before: str = None, 53 | after: str = None): 54 | """ 55 | Adds an optional dependency 56 | 57 | Add an optional dependency to the given `to` list, if it is resolvable 58 | by the importer. 59 | 60 | The module can be inserted at the right spot by using before or after 61 | keyword arguments. If both are given, the gun is pointing at your feet 62 | and before wins. If neither are given, the module is appended at the 63 | end. 64 | 65 | :param module: module to add, as it would be added to the given `to` list 66 | :param to: list to add the module to 67 | :param before: module string as should be available in the to list. 68 | :param after: module string as should be available in the to list. 69 | """ 70 | if not module_installed(module): 71 | return 72 | 73 | if not before and not after: 74 | to.append(module) 75 | return 76 | 77 | try: 78 | if before: 79 | pos = to.index(before) 80 | else: 81 | pos = to.index(after) + 1 82 | except ValueError: 83 | pass 84 | else: 85 | to.insert(pos, module) 86 | 87 | 88 | def install_optional(source: SOURCEREF, target: List[str]): 89 | """ 90 | Install optional modules 91 | 92 | Add a module as provided by the source reference to the target list. The 93 | source reference is a list of dictionaries: 94 | 95 | - key: module reference that needs to be inserted 96 | - value: a dictionary matching the keyword arguments to `add_optional_dep`: 97 | - before: module reference in `target`; the module will be inserted 98 | before this module. 99 | - after: module reference in `target`; the module will be inserted 100 | after this module. 101 | If value is `None`, the module will be appended to `target`. 102 | 103 | :param source: modules to install 104 | :param target: install into this list. 105 | """ 106 | for app in source: 107 | for ref, kwargs in app.items(): 108 | kwargs = kwargs or {} 109 | add_optional_dep(ref, target, **kwargs) 110 | -------------------------------------------------------------------------------- /src/crudlfap/crudlfap.py: -------------------------------------------------------------------------------- 1 | from crudlfap.models import URL, Controller 2 | from crudlfap.router import Router 3 | from crudlfap.views import generic 4 | 5 | 6 | class ControllerRouter(Router): 7 | model = Controller 8 | 9 | views = [ 10 | generic.DetailView, 11 | generic.ListView.clone( 12 | search_fields=( 13 | 'app', 14 | 'model', 15 | ), 16 | table_fields=( 17 | 'app', 18 | 'model', 19 | ), 20 | ), 21 | ] 22 | 23 | 24 | # useless ? 25 | # ControllerRouter().register() 26 | 27 | 28 | class URLRouter(Router): 29 | model = URL 30 | icon = 'link' 31 | 32 | views = [ 33 | generic.DetailView, 34 | generic.ListView.clone( 35 | search_fields=( 36 | 'name', 37 | 'controller__app', 38 | 'controller__model', 39 | ), 40 | table_fields=( 41 | 'controller', 42 | 'id', 43 | 'fullurlpath', 44 | ), 45 | ), 46 | ] 47 | 48 | 49 | URLRouter().register() 50 | -------------------------------------------------------------------------------- /src/crudlfap/factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | **CRIMINALLY INVASIVE HACKS** in :py:class:`Factory`. 3 | """ 4 | import inspect 5 | 6 | 7 | class FactoryMetaclass(type): 8 | """``__getattr__`` that ensures a first argument to getters. 9 | 10 | Makes the getter work both from class and instance 11 | 12 | Thanks to this, your `get_*()` methods will /maybe/ work in both 13 | cases:: 14 | 15 | YourClass.foo # calls get_foo(YourClass) 16 | YourClass().foo # calls get_foo(self) 17 | 18 | Don't code drunk. 19 | """ 20 | 21 | def __getattr__(cls, attr): 22 | if attr.startswith('get_'): 23 | raise AttributeError('{} or {}'.format(attr[4:], attr)) 24 | 25 | getter = getattr(cls, 'get_' + attr) 26 | 27 | if inspect.ismethod(getter): 28 | return getter() 29 | else: 30 | return getter(cls) 31 | 32 | def get_cls(cls): 33 | """Return the cls. 34 | 35 | did it go to far at this point ? 36 | """ 37 | return cls 38 | 39 | 40 | class Factory(metaclass=FactoryMetaclass): 41 | """Adds clumsy but automatic getter resolving. 42 | 43 | The `__getattr__` override makes this class try to call a get_*() method 44 | for variables that are not in `self.__dict__`. 45 | 46 | For example, when `self.foo` is evaluated `and 'foo' not in self.__dict__` 47 | then it will call the `self.get_foo()` 48 | 49 | If `self.get_foo()` returns None, it will try to get the result again from 50 | `self.__dict__`. Which means that we are going to witness this horrroorr:: 51 | 52 | class YourEvil(Factory): 53 | def get_foo(self): 54 | self.calls += 1 55 | self.foo = 13 56 | 57 | assert YourEvil.foo == 13 # crime scene 1 58 | assert YourEvil.foo == 13 # crime scene 2 59 | assert YourEvil.calls == 1 # crime scene 3 60 | 61 | For the moment it is pretty clumsy because i tried to contain the 62 | criminality rate as low as possible meanwhile i like the work it does for 63 | me ! 64 | """ 65 | def __getattr__(self, attr): 66 | if attr.startswith('get_'): 67 | raise AttributeError('{} or {}()'.format(attr[4:], attr)) 68 | 69 | getter = getattr(self, 'get_{}'.format(attr), None) 70 | 71 | if getter: 72 | methresult = getter() 73 | dictresult = self.__dict__.get(attr, None) 74 | if methresult is None and dictresult is not None: 75 | result = dictresult 76 | else: 77 | result = methresult 78 | return result 79 | 80 | # Try class methods 81 | return getattr(type(self), attr) 82 | 83 | @classmethod 84 | def clone(cls, *mixins, **attributes): 85 | """Return a subclass with the given attributes. 86 | 87 | If a model is found, it will prefix the class name with the model. 88 | """ 89 | name = cls.__name__ 90 | model = attributes.get('model', getattr(cls, 'model', None)) 91 | if model is None: 92 | model = getattr(cls, 'model', None) 93 | if model and model.__name__ not in cls.__name__: 94 | name = '{}{}'.format(model.__name__, cls.__name__) 95 | return type(name, (cls,) + mixins, attributes) 96 | -------------------------------------------------------------------------------- /src/crudlfap/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: django\n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2018-11-14 12:11+0100\n" 6 | "PO-Revision-Date: 2017-01-21 14:47+0000\n" 7 | "Last-Translator: Claude Paroz \n" 8 | "Language-Team: French (http://www.transifex.com/django/django/language/fr/)\n" 9 | "Language: fr\n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 14 | 15 | #: src/crudlfap/jinja2/crudlfap/_breadcrumb_object.html:1 16 | #: src/crudlfap/jinja2/crudlfap/_menu_main.html:2 17 | msgid "Home" 18 | msgstr "Accueil" 19 | 20 | #: src/crudlfap/jinja2/crudlfap/_menu_auth.html:13 21 | msgid "Back to your account" 22 | msgstr "Retour vers votre compte" 23 | 24 | #: src/crudlfap/jinja2/crudlfap/_modal.html:5 25 | msgid "Close" 26 | msgstr "Fermer" 27 | 28 | #: src/crudlfap/jinja2/crudlfap/form.html:28 29 | msgid "Objects ignored because of missing authorization" 30 | msgstr "Objets ignorés faute d'autorisation" 31 | 32 | #: src/crudlfap/jinja2/crudlfap/form.html:37 33 | msgid "cancel" 34 | msgstr "annuler" 35 | 36 | #: src/crudlfap/jinja2/crudlfap/list.html:50 37 | msgid "That page contains no results" 38 | msgstr "Cette page ne contient aucun résultat" 39 | 40 | #: src/crudlfap/mixins/lock.py:41 41 | msgid "Page was open by {} {}" 42 | msgstr "Page ouverte par {} {}" 43 | 44 | #: src/crudlfap/mixins/lock.py:42 45 | msgid "unknown user" 46 | msgstr "utilisateur inconnu" 47 | 48 | #: src/crudlfap/mixins/modelform.py:45 49 | msgid "failure" 50 | msgstr "échec" 51 | 52 | #: src/crudlfap/mixins/modelform.py:66 53 | msgid "detail" 54 | msgstr "détail" 55 | 56 | #: src/crudlfap/mixins/search.py:28 57 | msgid "Search" 58 | msgstr "Rechercher" 59 | 60 | msgid "home" 61 | msgstr "accueil" 62 | 63 | msgid "create" 64 | msgstr "ajouter" 65 | 66 | msgid "Create" 67 | msgstr "Création" 68 | 69 | msgid "delete" 70 | msgstr "effacer" 71 | 72 | msgid "update" 73 | msgstr "modifier" 74 | 75 | msgid "Update" 76 | msgstr "Modifier" 77 | 78 | msgid "list" 79 | msgstr "liste" 80 | 81 | msgid "List" 82 | msgstr "Liste" 83 | 84 | msgid "Select action to run on selected items" 85 | msgstr "Selectionner l'action a executer sur les lignes cochées" 86 | -------------------------------------------------------------------------------- /src/crudlfap/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap/management/__init__.py -------------------------------------------------------------------------------- /src/crudlfap/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap/management/commands/__init__.py -------------------------------------------------------------------------------- /src/crudlfap/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | 3 | from .crud import (ActionMixin, CreateMixin, DeleteMixin, DetailMixin, 4 | HistoryMixin, ListMixin, UpdateMixin) 5 | from .filter import FilterMixin 6 | from .form import FormMixin 7 | from .lock import LockMixin 8 | from .menu import MenuMixin 9 | from .model import ModelMixin 10 | from .modelform import ModelFormMixin, log, log_insert 11 | from .object import ObjectMixin 12 | from .objectform import ObjectFormMixin 13 | from .objects import ObjectsMixin 14 | from .objectsform import ObjectsFormMixin 15 | from .search import SearchMixin 16 | from .table import TableMixin 17 | from .template import TemplateMixin 18 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/filter.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django import forms 3 | from django.db import models 4 | 5 | 6 | class FilterMixin(object): 7 | def get_filter_model(self): 8 | return self.filterset_kwargs['queryset'].model 9 | 10 | def get_filterset(self): 11 | """ 12 | Returns an instance of the filterset to be used in this view. 13 | """ 14 | self.filterset = self.filterset_class(**self.filterset_kwargs) 15 | 16 | # filter out choices which have no result to avoid filter pollution 17 | # with choices which would empty out results 18 | for name, field in self.filterset.form.fields.items(): 19 | try: 20 | mf = self.filter_model._meta.get_field(name) 21 | except Exception: 22 | continue 23 | 24 | if not isinstance(mf, models.ForeignKey): 25 | continue 26 | 27 | field.queryset = field.queryset.annotate( 28 | c=models.Count(mf.related_query_name()) 29 | ).filter(c__gt=0) 30 | 31 | def get_filterset_data(self): 32 | return self.request.GET.copy() 33 | 34 | def get_filterset_kwargs(self): 35 | """ 36 | Returns the keyword arguments for instanciating the filterset. 37 | """ 38 | return { 39 | 'data': self.filterset_data, 40 | 'request': self.request, 41 | 'queryset': self.queryset, 42 | } 43 | 44 | def get_filterset_meta_filter_overrides(self): 45 | return { 46 | models.CharField: { 47 | 'filterset_class': django_filters.CharFilter, # noqa 48 | 'extra': lambda f: { 49 | 'lookup_expr': 'icontains', 50 | }, 51 | }, 52 | } 53 | 54 | def get_filter_fields(self): 55 | return [] 56 | 57 | def get_filterset_form_class(self): 58 | return type( 59 | 'FiltersetForm', 60 | (forms.Form,), 61 | { 62 | '_layout': self.filterset_form_layout, 63 | } 64 | ) 65 | 66 | def get_filterset_form_layout(self): 67 | return None 68 | 69 | def get_filterset_meta_attributes(self): 70 | return dict( 71 | model=self.filter_model, 72 | fields=self.filter_fields, 73 | filter_overrides=self.filterset_meta_filter_overrides, 74 | form=self.filterset_form_class, 75 | ) 76 | 77 | def get_filterset_meta_class(self): 78 | return type('Meta', (object,), self.filterset_meta_attributes) 79 | 80 | def get_filterset_extra_class_attributes(self): 81 | extra = dict() 82 | for field_name in self.filter_fields: 83 | try: 84 | field = self.filter_model._meta.get_field(field_name) 85 | except: # noqa 86 | continue 87 | choices = getattr(field, 'choices', None) 88 | if choices is None: 89 | continue 90 | extra[field_name] = django_filters.MultipleChoiceFilter( 91 | choices=choices) 92 | return extra 93 | 94 | def get_filterset_class_attributes(self): 95 | res = dict(Meta=self.filterset_meta_class) 96 | res.update(self.filterset_extra_class_attributes) 97 | return res 98 | 99 | def get_filterset_class(self): 100 | return type( 101 | '{}FilterSet'.format(self.filter_model.__name__), 102 | (django_filters.FilterSet,), 103 | self.filterset_class_attributes 104 | ) 105 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/form.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import http 4 | from django.contrib import messages 5 | 6 | 7 | class FormMixin: 8 | """Mixin for views which have a Form.""" 9 | 10 | initial = {} 11 | default_template_name = 'crudlfap/form.html' 12 | 13 | def get_context(self, **context): 14 | context['form'] = self.form 15 | return super().get_context(**context) 16 | 17 | def form_valid(self): 18 | """If the form is valid, redirect to the supplied URL.""" 19 | if self.request.content_type == 'application/json': 20 | return self.form_valid_json() 21 | 22 | self.message_success() 23 | return http.HttpResponseRedirect(self.success_url) 24 | 25 | def form_valid_json(self): 26 | return http.JsonResponse({ 27 | 'data': self.form.cleaned_data, 28 | 'status': 'accepted', 29 | }, status=201) 30 | 31 | def form_invalid_json(self): 32 | data = dict( 33 | status='invalid data', 34 | non_field_errors=self.form.non_field_errors(), 35 | field_errors=dict(), 36 | ) 37 | for name, errors in self.form.errors.items(): 38 | data['field_errors'][name] = errors 39 | return http.JsonResponse(data, status=405) 40 | 41 | def form_invalid(self): 42 | """If the form is invalid, render the invalid form.""" 43 | if self.request.content_type == 'application/json': 44 | return self.form_invalid_json() 45 | 46 | self.message_error() 47 | response = self.render_to_response() 48 | response.status_code = 400 49 | return response 50 | 51 | def get_title_submit(self): 52 | """ 53 | Title of the submit button. 54 | 55 | Defaults to :py:attr:`~crudlfap.mixins.menu.MenuMixin.title_menu` 56 | """ 57 | return self.title_menu 58 | 59 | def post(self, request, *args, **kwargs): 60 | """ 61 | Handle POST requests: instantiate a form instance with the passed 62 | POST variables and then check if it's valid. 63 | """ 64 | if self.form.is_valid(): 65 | return self.form_valid() 66 | else: 67 | return self.form_invalid() 68 | 69 | def get_initial(self): 70 | """Return the initial data to use for forms on this view.""" 71 | return self.initial.copy() 72 | 73 | def get_prefix(self): 74 | """Return the prefix to use for forms.""" 75 | return None 76 | 77 | def get_form_class(self): 78 | if self.router: 79 | return getattr(self.router, 'form_class') 80 | 81 | def get_form(self): 82 | """Return an instance of the form to be used in this view.""" 83 | self.form = self.form_class(**self.get_form_kwargs()) 84 | return self.form 85 | 86 | def get_form_kwargs(self): 87 | """Return the keyword arguments for instantiating the form.""" 88 | self.form_kwargs = { 89 | 'initial': self.get_initial(), 90 | 'prefix': self.get_prefix(), 91 | } 92 | 93 | if self.request.method in ('POST', 'PUT'): 94 | if self.request.content_type == 'application/json': 95 | data = json.loads(self.request.body) 96 | else: 97 | data = self.request.POST 98 | self.form_kwargs.update({ 99 | 'data': data, 100 | 'files': self.request.FILES, 101 | }) 102 | return self.form_kwargs 103 | 104 | def get_next_url(self): 105 | if '_next' in self.request.POST: 106 | self.next_url = self.request.POST.get('_next') 107 | if '_next' in self.request.GET: 108 | self.next_url = self.request.GET.get('_next') 109 | 110 | def get_success_url(self): 111 | if self.next_url: 112 | return self.next_url 113 | if self.router['list']: 114 | return self.router['list'].url 115 | return super().get_success_url() 116 | 117 | def message_html(self, message): 118 | return message 119 | 120 | def message_success(self): 121 | messages.success( 122 | self.request, 123 | self.message_html(self.form_valid_message) 124 | ) 125 | 126 | def message_error(self): 127 | messages.error( 128 | self.request, 129 | self.message_html(self.form_invalid_message) 130 | ) 131 | 132 | def get_swagger_summary(self): 133 | return self.title 134 | 135 | def get_swagger_post(self): 136 | result = { 137 | 'consumes': ['application/json'], 138 | # 'description': self.title, 139 | # 'operationId': 'addPet', 140 | 'parameters': [], 141 | 'produces': ['application/json', 'application/xml'], 142 | 'responses': { 143 | '400': { 144 | 'description': 'Invalid input' 145 | }, 146 | }, 147 | 'summary': self.swagger_summary, 148 | 'tags': self.swagger_tags, 149 | } 150 | for name, field in self.form_class.base_fields.items(): 151 | result['parameters'].append({ 152 | 'description': '. '.join([ 153 | str(field.label), 154 | str(field.help_text) 155 | ]), 156 | 'name': name, 157 | 'required': field.required, 158 | # 'schema': {'$ref': '#/definitions/Pet'} 159 | }) 160 | return result 161 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/lock.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import timeago 4 | from django.conf import settings 5 | from django.contrib import messages 6 | from django.core.cache import cache 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | class LockMixin(object): 12 | """Currently implements soft lock.""" 13 | locks = False 14 | 15 | def get_deadlock_delta(self): 16 | return datetime.timedelta(**self.deadlock_delta_kwargs) 17 | 18 | def get_deadlock_delta_kwargs(self): 19 | return dict(seconds=30) 20 | 21 | def get_lock_key(self): 22 | return 'lock-{}'.format(self.url) 23 | 24 | def get_lock_value(self): 25 | return cache.get(self.lock_key) 26 | 27 | def get_locked(self): 28 | if not self.locks: 29 | return False 30 | value = self.lock_value 31 | if not value: 32 | return False 33 | if value['datetime'] + self.deadlock_delta < timezone.now(): 34 | # expired lock 35 | cache.delete(self.lock_key) 36 | return False 37 | return True 38 | 39 | def get_locked_message(self): 40 | return _('Page was open by {} {}').format( 41 | self.lock_value.get('username', _('unknown user')), 42 | timeago.format( 43 | timezone.now() - self.lock_value['datetime'], 44 | getattr(self.request, 'LANGUAGE_CODE', settings.LANGUAGE_CODE) 45 | ) 46 | ) 47 | 48 | def get(self, request, *args, **kwargs): 49 | if self.locked: 50 | messages.warning(request, self.locked_message) 51 | if self.locks: 52 | self.lock() 53 | return super().get(request, *args, **kwargs) 54 | 55 | def lock(self): 56 | value = dict(datetime=timezone.now()) 57 | if self.request.user.is_authenticated: 58 | value['username'] = self.request.user.username 59 | cache.set(self.lock_key, value) 60 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/menu.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | 4 | class MenuMixin(object): 5 | def get_title_menu(self): 6 | """Return title for menu links to this view.""" 7 | return _(self.view_label).capitalize() 8 | 9 | def get_menu(self): 10 | return None 11 | 12 | def get_menu_kwargs(self): 13 | return dict() 14 | 15 | def get_menu_views(self): 16 | views = [] 17 | for view in self.router.views: 18 | for menu in view.menus: 19 | if menu in self.menus_display: 20 | view = view.clone( 21 | request=self.request, 22 | **self.menu_kwargs, 23 | ) 24 | 25 | if not view().has_perm(): 26 | continue 27 | if view.urlname == self.urlname: 28 | continue 29 | if view.urlname in [v.urlname for v in views]: 30 | continue 31 | views.append(view) 32 | return views 33 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/model.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.utils.translation import gettext as _ 3 | 4 | 5 | class ModelMixin(object): 6 | """Mixin for views using a Model class but no instance.""" 7 | model = None 8 | 9 | menus = ['model'] 10 | menus_display = ['model'] 11 | pluralize = False 12 | 13 | def get_exclude(self): 14 | return [] 15 | 16 | def get_fields(self): 17 | return [ 18 | f for f in self.router.get_fields(self) 19 | if self.model._meta.get_field(f).editable 20 | and f not in self.exclude 21 | ] 22 | 23 | def get_model_verbose_name(self): 24 | if self.pluralize: 25 | return self.model._meta.verbose_name_plural 26 | else: 27 | return self.model._meta.verbose_name 28 | 29 | def get_title(self): 30 | """Compose a title of Model Name: View label.""" 31 | return '{}: {}'.format( 32 | self.model_verbose_name.capitalize(), 33 | _(self.view_label), 34 | ).capitalize() 35 | 36 | def get_queryset(self): 37 | """Return router.get_queryset() by default, otherwise super().""" 38 | if self.router: 39 | return self.router.get_queryset(self) 40 | 41 | if self.model: 42 | return self.model._default_manager.all() 43 | 44 | raise ImproperlyConfigured( 45 | "%(cls)s is missing a QuerySet. Define " 46 | "%(cls)s.model, %(cls)s.queryset, or override " 47 | "%(cls)s.get_queryset()." % { 48 | 'cls': self.__class__.__name__ 49 | } 50 | ) 51 | 52 | def get_swagger_model_name(self): 53 | if self.model: 54 | return self.model.__name__ 55 | 56 | def get_swagger_tags(self): 57 | if self.model: 58 | return [self.model.__name__] 59 | return [] 60 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/modelform.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import http 4 | from django.contrib.admin.models import LogEntry 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.forms import models as model_forms 7 | from django.utils.translation import gettext as _ 8 | 9 | from .form import FormMixin 10 | from .model import ModelMixin 11 | 12 | 13 | def log(user, flag, message, obj=None, dt=None): 14 | from django.contrib.admin.models import ADDITION, CHANGE, DELETION 15 | flags = dict( 16 | create=ADDITION, 17 | update=CHANGE, 18 | delete=DELETION 19 | ) 20 | flag = flags.get(flag, flag) 21 | if not isinstance(message, str): 22 | message = json.dumps(message) 23 | logentry = LogEntry( 24 | user_id=user.pk, 25 | action_flag=flag, 26 | change_message=message, 27 | ) 28 | if obj: 29 | logentry.content_type_id = ContentType.objects.get_for_model(obj).pk 30 | logentry.object_id = obj.pk 31 | logentry.object_repr = str(obj)[:200] 32 | if dt: 33 | logentry.action_time = dt 34 | logentry.save() 35 | return logentry 36 | 37 | 38 | log_insert = log # backward compat 39 | 40 | 41 | class ModelFormMixin(ModelMixin, FormMixin): 42 | """ModelForm Mixin using readable""" 43 | menus = ['model'] 44 | 45 | def form_valid_json(self): 46 | return http.JsonResponse({ 47 | 'data': self.router.serialize(self.object), 48 | 'status': 'created', 49 | }, status=201) 50 | 51 | def get_form_kwargs(self): 52 | self.form_kwargs = super().get_form_kwargs() 53 | if ( 54 | hasattr(self, 'object') 55 | and issubclass(self.form_class, model_forms.ModelForm) 56 | ): 57 | self.form_kwargs.update({'instance': self.object}) 58 | return self.form_kwargs 59 | 60 | def get_form(self): 61 | self.form = self.form_class(**self.form_kwargs) 62 | return self.form 63 | 64 | def get_form_fields(self): 65 | return self.fields 66 | 67 | def get_form_base(self): 68 | return None 69 | 70 | def get_modelform_kwargs(self): 71 | kwargs = dict() 72 | if self.form_base: 73 | kwargs['form'] = self.form_base 74 | kwargs['fields'] = self.form_fields 75 | return kwargs 76 | 77 | def get_form_class(self): 78 | return model_forms.modelform_factory( 79 | self.model, 80 | **self.modelform_kwargs 81 | ) 82 | 83 | def get_form_invalid_message(self): 84 | return '{}: {}: {}'.format( 85 | _(self.view_label), 86 | self.model_verbose_name, 87 | _('failure'), 88 | ).capitalize() 89 | 90 | def get_form_valid_message(self): 91 | return '{}: {}'.format( 92 | _(self.view_label), 93 | self.form.instance, 94 | ).capitalize() 95 | 96 | def message_html(self, message): 97 | return message 98 | 99 | def get_log_action_flag(self): 100 | return False 101 | 102 | def get_log_message(self): 103 | return _(self.view_label) 104 | 105 | def log_insert(self): 106 | if not LogEntry: 107 | return 108 | 109 | if not self.request.user.is_authenticated: 110 | return 111 | 112 | if not self.log_action_flag: 113 | return 114 | 115 | if hasattr(self, 'object_list'): 116 | objects = self.object_list 117 | elif hasattr(self, 'log_objects'): 118 | objects = self.log_objects 119 | else: 120 | objects = [self.object] 121 | 122 | for obj in objects: 123 | log_insert( 124 | self.request.user, 125 | self.log_action_flag, 126 | self.log_message, 127 | obj, 128 | ) 129 | 130 | def form_valid(self): 131 | response = super().form_valid() 132 | self.log_insert() 133 | return response 134 | 135 | def get_success_url(self): 136 | if self.next_url: 137 | return self.next_url 138 | 139 | if hasattr(self.object, 'get_absolute_url'): 140 | return self.object.get_absolute_url() 141 | 142 | return super().get_success_url() 143 | 144 | def get_swagger_summary(self): 145 | return f'{self.model.__name__} {self.label}' 146 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/object.py: -------------------------------------------------------------------------------- 1 | from django import http 2 | from django.conf import settings 3 | from django.contrib.admin.models import LogEntry 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.db import models 7 | from django.db.models import Q 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from .model import ModelMixin 11 | 12 | 13 | class ObjectMixin(ModelMixin): 14 | """Mixin for views using a Model instance.""" 15 | 16 | menus = ['object', 'object_detail'] 17 | menus_display = ['object', 'object_detail'] 18 | template_name_field = None 19 | 20 | def logentries(self): 21 | filters = Q( 22 | content_type=ContentType.objects.get_for_model(self.model), 23 | object_id=self.object.pk, 24 | ) 25 | name = '.'.join([ 26 | self.object._meta.app_label, 27 | self.object._meta.model_name 28 | ]) 29 | if name.lower() == settings.AUTH_USER_MODEL.lower(): 30 | filters |= Q(user_id=self.object.pk) 31 | return LogEntry.objects.filter(filters).order_by('-action_time') 32 | 33 | def get_context(self, **context): 34 | context['object'] = self.object 35 | return super().get_context(**context) 36 | 37 | def get_template_name_suffix(self): 38 | return '_{}'.format(self.urlname) 39 | 40 | def get_urlargs(self): 41 | """Return list with object's urlfield attribute.""" 42 | return [getattr(self.object, self.urlfield)] 43 | 44 | @classmethod 45 | def to_url_args(cls, *args): 46 | """Return first arg's url_field attribute.""" 47 | url_field = cls.get_url_field() 48 | return [getattr(args[0], url_field)] 49 | 50 | @classmethod 51 | def get_urlpath(cls): 52 | """Identify the object by slug or pk in the pattern.""" 53 | return r'<{}>/{}'.format(cls.urlfield, cls.urlname) 54 | 55 | def get_title(self): 56 | return '{} "{}": {}'.format( 57 | self.model_verbose_name, 58 | self.object, 59 | _(self.view_label).capitalize(), 60 | ).capitalize() 61 | 62 | def get_menu_kwargs(self): 63 | return dict(object=self.object) 64 | 65 | def get_object(self, queryset=None): 66 | """ 67 | Return the object the view is displaying. 68 | 69 | Require `self.queryset` and a `pk` or `slug` argument in the URLconf. 70 | Subclasses can override this to return any object. 71 | """ 72 | if getattr(self, 'kwargs', False) is False: 73 | # This happens when the view has not been instanciated with an 74 | # object, neither from a URL which would allow getting the object 75 | # in the super() call below. 76 | raise Exception('Must instanciate the view with an object') 77 | 78 | # Use a custom queryset if provided; this is required for subclasses 79 | # like DateDetailView 80 | if queryset is None: 81 | queryset = self.get_queryset() 82 | 83 | queryset = queryset.filter( 84 | **{self.urlfield: self.kwargs.get(self.urlfield)} 85 | ) 86 | 87 | try: 88 | # Get the single item from the filtered queryset 89 | obj = queryset.get() 90 | except queryset.model.DoesNotExist: 91 | raise http.Http404( 92 | _("No %(verbose_name)s found matching the query") % 93 | {'verbose_name': queryset.model._meta.verbose_name} 94 | ) 95 | 96 | return obj 97 | 98 | def object_get(self): 99 | """Return the object, uses get_object() if necessary.""" 100 | cached = getattr(self, '_object', None) 101 | if not cached: 102 | self._object = self.get_object() 103 | return self._object 104 | 105 | def object_set(self, value): 106 | """Set self.object attribute.""" 107 | self._object = value 108 | 109 | object = property(object_get, object_set) 110 | 111 | def get_template_names(self): 112 | """ 113 | Return a list of template names to be used for the request. 114 | 115 | May not be called if render_to_response() is overridden. Return the 116 | following list: 117 | 118 | * the value of ``template_name`` on the view (if provided) 119 | * the contents of the ``template_name_field`` field on the 120 | object instance that the view is operating upon (if available) 121 | * ``/.html`` 122 | """ 123 | try: 124 | names = super().get_template_names() 125 | except (ImproperlyConfigured, AttributeError): 126 | # If template_name isn't specified, it's not a problem -- 127 | # we just start with an empty list. 128 | names = [] 129 | 130 | # If self.template_name_field is set, grab the value of the field 131 | # of that name from the object; this is the most specific template 132 | # name, if given. 133 | if self.object and self.template_name_field: 134 | name = getattr(self.object, self.template_name_field, None) 135 | if name: 136 | names.insert(0, name) 137 | 138 | # The least-specific option is the default 139 | # /_detail.html; only use this if the object in 140 | # question is a model. 141 | if isinstance(self.object, models.Model): 142 | object_meta = self.object._meta 143 | names.append("%s/%s%s.html" % ( 144 | object_meta.app_label, 145 | object_meta.model_name, 146 | self.template_name_suffix 147 | )) 148 | elif self.model and issubclass(self.model, models.Model): 149 | names.append("%s/%s%s.html" % ( 150 | self.model._meta.app_label, 151 | self.model._meta.model_name, 152 | self.template_name_suffix 153 | )) 154 | 155 | # If we still haven't managed to find any template names, we should 156 | # re-raise the ImproperlyConfigured to alert the user. 157 | if not names: 158 | raise 159 | 160 | return names 161 | 162 | def get_abstract_object(self): 163 | return self.model() 164 | 165 | @classmethod 166 | def abstract(cls, **kwargs): 167 | if 'object' not in kwargs: 168 | kwargs['object'] = cls.abstract_object 169 | return cls(**kwargs) 170 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/objectform.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.models import CHANGE 2 | from django.utils.translation import gettext as _ 3 | 4 | from .modelform import ModelFormMixin 5 | from .object import ObjectMixin 6 | 7 | 8 | class ObjectFormMixin(ObjectMixin, ModelFormMixin): 9 | """Custom form view mixin on an object.""" 10 | log_action_flag = CHANGE 11 | 12 | def get_form_valid_message(self): 13 | return _( 14 | '%s %s: {}' % (_(self.view_label), self.model_verbose_name) 15 | ).format(self.object).capitalize() 16 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/objects.py: -------------------------------------------------------------------------------- 1 | from .model import ModelMixin 2 | 3 | 4 | class ObjectsMixin(ModelMixin): 5 | pluralize = True 6 | 7 | def get_objects(self): 8 | return self.queryset 9 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/objectsform.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | from .modelform import ModelFormMixin 4 | 5 | 6 | class ObjectsFormMixin(ModelFormMixin): 7 | pluralize = True 8 | menus = ['list_action'] 9 | 10 | def get_invalid_pks(self): 11 | return len(self.request.GET.getlist('pk')) - len(self.object_list) 12 | 13 | def get_object_list(self): 14 | self.object_list = self.queryset.filter( 15 | pk__in=self.request.GET.getlist('pk') 16 | ) 17 | return self.object_list 18 | 19 | def get_success_url(self): 20 | return self.router['list'].reverse() 21 | 22 | def get_form_valid_message(self): 23 | return '{}: {}'.format( 24 | _(self.view_label), 25 | ', '.join([str(o) for o in self.object_list]), 26 | ).capitalize() 27 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/search.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from functools import reduce 3 | 4 | from django import forms 5 | from django.db import models 6 | from django.db.models import Q 7 | from django.utils.translation import gettext as _ 8 | 9 | 10 | class SearchForm(forms.Form): 11 | q = forms.CharField(label=_('Search'), required=False) 12 | 13 | 14 | class SearchMixin(object): 15 | def get_search_model(self): 16 | return self.model 17 | 18 | def get_search_fields(self): 19 | if hasattr(self.router, 'search_fields'): 20 | return self.router.search_fields 21 | return [ 22 | f.name 23 | for f in self.search_model._meta.fields 24 | if isinstance(f, models.CharField) 25 | ] 26 | 27 | def get_search_form_class(self): 28 | if not self.search_fields: 29 | return 30 | 31 | return SearchForm 32 | 33 | def get_search_form(self): 34 | if self.search_fields: 35 | self.search_form = self.search_form_class( 36 | self.request.GET, 37 | ) 38 | self.search_form.full_clean() 39 | else: 40 | self.search_form = None 41 | return self.search_form 42 | 43 | def search_filter(self, qs): 44 | q = self.search_form.cleaned_data.get('q', '') 45 | 46 | if not self.search_fields or not q: 47 | return qs 48 | 49 | return qs.filter(reduce( 50 | operator.or_, 51 | [ 52 | Q(**{search_field + '__icontains': q}) 53 | for search_field in self.search_fields 54 | ] 55 | )).distinct() 56 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/table.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | import django_tables2 as tables 4 | from django.db import models 5 | from django.utils.safestring import mark_safe 6 | from django.utils.translation import gettext_lazy as _ 7 | from django_tables2.config import RequestConfig 8 | 9 | from crudlfap import html 10 | 11 | 12 | class UnpolyLinkColumn(tables.LinkColumn): 13 | def render(self, record, value): 14 | return super().render(record, value).replace( 15 | ' self.max_per_page: 153 | return self.max_per_page 154 | return dict(per_page=per_page) 155 | 156 | def get_table(self): 157 | kwargs = self.table_kwargs 158 | self.table = self.build_table_class()(**kwargs) 159 | RequestConfig( 160 | self.request, 161 | paginate=self.table_pagination, 162 | ).configure(self.table) 163 | return self.table 164 | 165 | def get_paginate_by(self): 166 | return 10 167 | -------------------------------------------------------------------------------- /src/crudlfap/mixins/template.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.template.response import TemplateResponse 5 | from django.utils.translation import gettext as _ 6 | 7 | logger = logging.getLogger() 8 | 9 | 10 | class TemplateMixin: 11 | """ 12 | Replacement for Django's TemplateResponseMixin. 13 | 14 | This allows to configure "last resort" templates for each class, and thus 15 | to provide a working CRUD out of the box. 16 | """ 17 | 18 | def get(self, request, *args, **kwargs): 19 | return self.render_to_response() 20 | 21 | def render_to_response(self): 22 | """ 23 | Return a response, using the `response_class` for this view, with a 24 | template rendered with the given context. 25 | 26 | Pass response_kwargs to the constructor of the response class. 27 | """ 28 | return self.response_class( 29 | request=self.request, 30 | template=self.template_names, 31 | context=self.context, 32 | using=self.template_engine, 33 | **self.response_kwargs 34 | ) 35 | 36 | def get_template_engine(self): 37 | return getattr( 38 | settings, 'CRUDLFAP', {} 39 | ).get('TEMPLATE_ENGINE', 'ryzom') 40 | 41 | def get_response_class(self): 42 | return TemplateResponse 43 | 44 | def get_response_kwargs(self): 45 | return dict(content_type='text/html') 46 | 47 | def get_context(self, **context): 48 | context['view'] = self 49 | self.context = context 50 | 51 | def get_view_label(self): 52 | return self.label 53 | 54 | def get_title(self): 55 | return _(self.view_label).capitalize() 56 | 57 | def get_title_link(self): 58 | """Return title attribute for links to this view.""" 59 | return self.title 60 | 61 | def get_title_html(self): 62 | """Return text for HTML title tag.""" 63 | return self.title 64 | 65 | def get_title_heading(self): 66 | """Return text for page heading.""" 67 | return self.title 68 | 69 | def get_template_name_suffix(self): 70 | return self.urlname 71 | 72 | def get_template_name_suffixes(self): 73 | return [self.template_name_suffix] 74 | 75 | def get_template_names(self): 76 | """Give a chance to default_template_name.""" 77 | template_names = [] 78 | template_name = getattr(self, 'template_name', None) 79 | if template_name: 80 | template_names.append(template_name) 81 | 82 | if getattr(self, 'model', None): 83 | template_names.append('%s/%s%s.html' % ( 84 | self.model._meta.app_label, 85 | self.model._meta.model_name, 86 | '_' + self.urlname, 87 | )) 88 | 89 | default_template_name = getattr(self, 'default_template_name', None) 90 | if default_template_name: 91 | template_names.append(default_template_name) 92 | return template_names 93 | -------------------------------------------------------------------------------- /src/crudlfap/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from lookupy import Collection 5 | 6 | from crudlfap.site import site as default_site 7 | 8 | 9 | class ControllerManager(models.Manager): 10 | def __init__(self, site=None): 11 | super().__init__() 12 | self.site = site or default_site 13 | 14 | def get_queryset(self): 15 | items = [ 16 | Controller.factory(i) for i in self.site.values() 17 | ] 18 | return Collection(items) 19 | 20 | 21 | class Controller(models.Model): 22 | router = models.Field() 23 | model = models.Field() 24 | app = models.Field() 25 | 26 | objects = ControllerManager() 27 | 28 | class Meta: 29 | managed = False 30 | 31 | def __str__(self): 32 | return self.pk 33 | 34 | @classmethod 35 | def factory(cls, router): 36 | return cls( 37 | pk=f'{router.model._meta.app_label}.{router.model.__name__}', 38 | router=router, 39 | app=str(router.model._meta.app_config.verbose_name), 40 | model=str(router.model._meta.verbose_name).capitalize(), 41 | ) 42 | 43 | 44 | class URLManager(models.Manager): 45 | def __init__(self, site=None): 46 | super().__init__() 47 | self.site = site or default_site 48 | 49 | def get_queryset(self): 50 | views = [ 51 | URL.factory(i) for i in self.site.views 52 | ] 53 | 54 | for router in self.site.values(): 55 | views += [ 56 | URL.factory(i) for i in router.views 57 | ] 58 | 59 | return Collection(views) 60 | 61 | 62 | class URL(models.Model): 63 | controller = models.ForeignKey( 64 | Controller, 65 | on_delete=models.SET_NULL, 66 | null=True, 67 | ) 68 | urlpath = models.Field() 69 | fullurlpath = models.Field() 70 | urlname = models.Field() 71 | urlfullname = models.Field() 72 | view = models.Field() 73 | model = models.Field() 74 | 75 | objects = URLManager() 76 | 77 | class Meta: 78 | managed = False 79 | 80 | def __str__(self): 81 | return self.view.label 82 | 83 | @property 84 | def content_type(self): 85 | return ContentType.objects.get_for_model(self.view.model) 86 | 87 | @property 88 | def codename(self): 89 | if not self.view.model: 90 | return self.view.permission_shortcode 91 | return '_'.join(( 92 | self.view.permission_shortcode, 93 | self.view.model.__name__.lower(), 94 | )) 95 | 96 | def get_or_create_permission(self): 97 | return Permission.objects.update_or_create( 98 | content_type=self.content_type, 99 | codename=self.codename, 100 | defaults=dict( 101 | name=getattr( 102 | self.view, 103 | 'title_menu', 104 | self.view.permission_shortcode 105 | ), 106 | ) 107 | )[0] 108 | 109 | @classmethod 110 | def factory(cls, view): 111 | kwargs = dict( 112 | pk=f'{view.__name__}', 113 | view=view, 114 | urlpath=view.urlpath, 115 | urlname=view.urlname, 116 | urlfullname=view.urlfullname, 117 | ) 118 | 119 | if view.model: 120 | kwargs['model'] = view.model 121 | 122 | if view.router: 123 | kwargs['controller'] = Controller.factory(view.router) 124 | kwargs['fullurlpath'] = '/'.join(( 125 | view.router.registry.urlpath, 126 | view.router.urlpath, 127 | view.urlpath, 128 | )) 129 | 130 | url = cls(**kwargs) 131 | return url 132 | -------------------------------------------------------------------------------- /src/crudlfap/registry.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | from django.apps import apps 4 | from django.urls import path 5 | 6 | from .factory import Factory 7 | from .router import ViewsDescriptor 8 | 9 | crudlfap = apps.get_app_config('crudlfap') # pylint: disable=invalid-name 10 | 11 | 12 | class Registry(Factory, collections.OrderedDict): 13 | views = ViewsDescriptor() 14 | 15 | def get_menu(self, name, request, **kwargs): 16 | result = [] 17 | for view in self.views: 18 | menus = getattr(view, 'menus', []) 19 | if name in menus and view(request=request).has_perm(): 20 | result.append(view) 21 | for model, router in self.items(): 22 | menu = router.get_menu(name, request, **kwargs) 23 | if not menu: 24 | continue 25 | result += menu 26 | return result 27 | 28 | def get_app_menus(self, name, request, **kwargs): 29 | """Sort Router instances by app name.""" 30 | result = collections.OrderedDict() 31 | for model, router in self.items(): 32 | menu = router.get_menu(name, request, **kwargs) 33 | 34 | if not menu: 35 | continue 36 | 37 | app = apps.get_app_config(model._meta.app_label) 38 | result.setdefault(app, []) 39 | result[app].append(router) 40 | return result 41 | 42 | def __getitem__(self, arg): 43 | """Return a router instance by model class, instance or dotted name.""" 44 | from django.db import models 45 | if isinstance(arg, models.Model): 46 | arg = type(arg) 47 | elif isinstance(arg, str) and '.' in arg: 48 | arg = apps.get_model(*arg.split('.')) 49 | return super().__getitem__(arg) 50 | 51 | def __init__(self, views=None, *a, **attrs): 52 | self.views = views or [] 53 | super().__init__(*a) 54 | for k, v in attrs.items(): 55 | setattr(self, k, v) 56 | 57 | def get_urlpatterns(self): 58 | for view in self.views: 59 | view.registry = self 60 | 61 | return [ 62 | router.urlpattern for router in self.values() 63 | ] + [view.urlpattern for view in self.views] 64 | 65 | def get_urlpattern(self): 66 | urlpath = self.urlpath 67 | if urlpath and not urlpath.endswith('/'): 68 | urlpath += '/' 69 | 70 | return path(urlpath, ( 71 | self.urlpatterns, 72 | self.app_name, 73 | self.namespace, 74 | )) 75 | 76 | def get_app_name(self): 77 | return 'crudlfap' 78 | 79 | def get_namespace(self): 80 | return None 81 | 82 | def get_urlpath(self): 83 | return '' 84 | 85 | def get_title(self): 86 | return 'CRUDLFA+' 87 | 88 | def get_navbar_color(self): 89 | return '' 90 | -------------------------------------------------------------------------------- /src/crudlfap/shortcuts.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | """Import everything we expose in crudlfap namespace.""" 3 | 4 | from .apps import _installed 5 | from .factory import Factory 6 | from .mixins import * # noqa 7 | from .registry import Registry 8 | from .route import Route 9 | from .router import Router, Views, ViewsDescriptor 10 | from .site import site 11 | from .views import * # noqa 12 | -------------------------------------------------------------------------------- /src/crudlfap/site.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from .registry import Registry 4 | from .views.generic import TemplateView 5 | from .views.api import SchemaView 6 | 7 | site = Registry( 8 | views=[ 9 | TemplateView.clone( 10 | icon='home', 11 | template_name='crudlfap/home.html', 12 | menus=['main'], 13 | title=_('Home'), 14 | title_menu=_('Home'), 15 | title_heading='', 16 | urlname='home', 17 | urlpath='', 18 | authenticate=False, 19 | ), 20 | SchemaView, 21 | TemplateView.clone( 22 | icon='api', 23 | template_name='crudlfap/api.html', 24 | menus=['main'], 25 | title=_('API'), 26 | title_menu=_('Api'), 27 | title_heading='', 28 | urlname='api', 29 | urlpath='api', 30 | authenticate=False, 31 | unpoly=False, 32 | ), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /src/crudlfap/test_conf.py: -------------------------------------------------------------------------------- 1 | from .conf import install_optional 2 | 3 | 4 | def get_installed(): 5 | return ['django.contrib.staticfiles', 'crudlfap', 'myapp'].copy() 6 | 7 | 8 | def test_install_optional(): 9 | bs3 = {'debug_toolbar': {'after': 'django.contrib.staticfiles'}} 10 | messages = { 11 | 'django.contrib.messages': { 12 | 'before': 'django.contrib.staticfiles' 13 | } 14 | } 15 | nonexistent = { 16 | 'nonexistent': None, 17 | } 18 | ctx = { 19 | 'crudlfap_auth': None, 20 | } 21 | after = get_installed() 22 | install_optional([bs3], after) 23 | expect = [ 24 | 'django.contrib.staticfiles', 25 | 'debug_toolbar', 26 | 'crudlfap', 27 | 'myapp', 28 | ] 29 | assert after == expect, "Failed to insert bootstrap3 after crudlfap" 30 | before_and_after = get_installed() 31 | install_optional([bs3, messages], before_and_after) 32 | expected = ['django.contrib.messages', 'django.contrib.staticfiles', 33 | 'debug_toolbar', 'crudlfap', 'myapp'] 34 | assert before_and_after == expected, "Failed before and after" 35 | nono = get_installed() 36 | install_optional([nonexistent], nono) 37 | assert nono == get_installed(), "Non existing app installed" 38 | mod_attr = get_installed() 39 | install_optional([ctx], mod_attr) 40 | expected = ['django.contrib.staticfiles', 'crudlfap', 'myapp', 41 | 'crudlfap_auth'] 42 | assert mod_attr == expected, "Failed to append module attribute reference" 43 | -------------------------------------------------------------------------------- /src/crudlfap/test_factory.py: -------------------------------------------------------------------------------- 1 | from crudlfap_example.artist.models import Artist 2 | 3 | from .factory import Factory 4 | 5 | 6 | def test_factory(): 7 | assert Factory.clone(foo=1).foo == 1 8 | 9 | 10 | def test_factory_with_model_property(): 11 | class Router(Factory): 12 | model = Artist 13 | 14 | assert Router.clone(model=Artist).__name__ == 'ArtistRouter' 15 | 16 | 17 | def test_factory_with_model_argument(): 18 | class Router(Factory): 19 | pass 20 | 21 | assert Router.clone(model=Artist).__name__ == 'ArtistRouter' 22 | -------------------------------------------------------------------------------- /src/crudlfap/test_route.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.views import generic 4 | 5 | from crudlfap_example.artist.models import Artist 6 | 7 | from .route import Route 8 | from .router import Router 9 | 10 | 11 | def test_model(): 12 | route = Route.clone(model=Artist) 13 | assert route.model == Artist 14 | 15 | 16 | def test_model_router_fallback(): 17 | route = Router(Artist, fields='__all__', views=[Route]).views[0] 18 | assert route.model == Artist 19 | 20 | 21 | def test_urlname(): 22 | route = Route.clone(urlname='foo') 23 | assert route.urlname == 'foo' 24 | 25 | 26 | def test_urlname_from_name(): 27 | class FooRoute(Route): 28 | pass 29 | assert FooRoute.urlname == 'foo' 30 | 31 | 32 | def test_urlpath(): 33 | route = Route.clone(urlpath='foo') 34 | assert route.urlpath == 'foo' 35 | 36 | 37 | def test_urlpath_from_urlname(): 38 | class FooRoute(Route): 39 | pass 40 | assert FooRoute.urlpath == 'foo' 41 | 42 | 43 | def test_urlpattern(): 44 | class DetailView(generic.DetailView, Route): 45 | model = Artist 46 | p = DetailView.urlpattern 47 | assert p.name == 'detail' 48 | assert p.pattern.regex == re.compile('^detail\\Z') 49 | assert p.callback.view_class == DetailView 50 | -------------------------------------------------------------------------------- /src/crudlfap/test_router.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import models 3 | from django.urls import resolve, reverse 4 | from django.views import generic 5 | 6 | from crudlfap import shortcuts as crudlfap 7 | from crudlfap_example.artist.models import Artist 8 | 9 | from .route import Route 10 | from .router import Router 11 | 12 | 13 | class TestRouter(Router): 14 | __test__ = False 15 | fields = '__all__' 16 | 17 | 18 | def test_views(): 19 | router = TestRouter() 20 | view = crudlfap.TemplateView.clone(urlname='home') 21 | router.views = [view] 22 | assert router.views['home'] == view 23 | assert router.views[0].urlname == 'home' 24 | 25 | router.views['home'] = router.views['home'].clone(template_name='lol') 26 | assert router.views['home'].template_name == 'lol' 27 | 28 | del router.views['home'] 29 | assert len(router.views) == 0 30 | 31 | 32 | def test_urlfield(): 33 | assert Router().urlfield is None 34 | 35 | 36 | def test_urlfield_with_model(): 37 | class TestModel(models.Model): 38 | pass 39 | assert Router(model=TestModel).urlfield == 'pk' 40 | 41 | 42 | def test_urlfield_with_slug(): 43 | class TestModel(models.Model): 44 | slug = models.CharField(max_length=100) 45 | assert Router(model=TestModel).urlfield == 'slug' 46 | 47 | 48 | def test_namespace_none(): 49 | assert Router().namespace is None 50 | 51 | 52 | def test_namespace(): 53 | assert Router(namespace='lol').namespace == 'lol' 54 | 55 | 56 | def test_namespace_with_model(): 57 | assert Router(model=Artist).namespace == 'artist' 58 | 59 | 60 | def test_path(): 61 | assert Router(urlpath='foo').urlpath == 'foo' 62 | 63 | 64 | def test_path_with_model(): 65 | assert Router(model=Artist).urlpath == 'artist' 66 | 67 | 68 | def test_app_name(): 69 | assert Router(app_name='lol').app_name == 'lol' 70 | 71 | 72 | def test_app_name_with_model(): 73 | assert Router(model=Artist).app_name == 'artist' 74 | 75 | 76 | def test_registry_default(): 77 | assert Router().registry == crudlfap.site 78 | 79 | 80 | def test_registry(): 81 | site = dict() 82 | assert Router(registry=site).registry == site 83 | 84 | 85 | class DetailView(Route, generic.DetailView): 86 | menus = ['object'] 87 | 88 | # Not setting this would require 89 | # request.user.has_perm('artist.detail_artist', obj) to pass 90 | allowed = True 91 | 92 | # This is done by crudlfap generic ObjectView, but here tests django 93 | # generic views 94 | def get_urlargs(self): 95 | # This may be executed with just the class context (self.object 96 | # resolving to type(self).object, as from 97 | # Route.clone(object=something).url 98 | return [self.object.name] 99 | 100 | 101 | @pytest.fixture 102 | def router(): 103 | return Router(model=Artist, views=[DetailView]) 104 | 105 | 106 | def test_getitem(router): 107 | assert issubclass(router['detail'], DetailView) 108 | 109 | 110 | def test_urlpatterns(router): 111 | assert len(router.urlpatterns) == 1 112 | assert router.urlpatterns[0].name == 'detail' 113 | 114 | 115 | def test_urlpattern(router): 116 | assert reverse('detail', router) == '/detail' 117 | assert resolve('/detail', router).func.view_class == router.views[0] 118 | 119 | 120 | @pytest.mark.django_db 121 | def test_get_menu(router, srf): 122 | a = Artist(name='a') 123 | from crudlfap_auth.crudlfap import User 124 | srf.user = User.objects.create(is_superuser=True) 125 | req = srf.get('/') 126 | result = router.get_menu('object', req, object=a) 127 | assert len(result) == 1 128 | assert isinstance(result[0], DetailView) 129 | assert result[0].urlargs == ['a'] 130 | assert type(result[0]).urlargs == ['a'] 131 | assert str(result[0].url) == '/artist/a' 132 | 133 | b = type(result[0]).clone(object=a) 134 | assert str(b.url) == '/artist/a' 135 | -------------------------------------------------------------------------------- /src/crudlfap/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import FieldDoesNotExist 2 | 3 | 4 | def guess_urlfield(model): 5 | if not model: 6 | return None 7 | 8 | try: 9 | model._meta.get_field('slug') 10 | except FieldDoesNotExist: 11 | pass 12 | else: 13 | return 'slug' 14 | 15 | return 'pk' 16 | -------------------------------------------------------------------------------- /src/crudlfap/views/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | 3 | from .generic import ( 4 | CreateView, 5 | DeleteView, 6 | DeleteObjectsView, 7 | DetailView, 8 | FormView, 9 | HistoryView, 10 | ListView, 11 | ModelFormView, 12 | ModelView, 13 | ObjectFormView, 14 | ObjectView, 15 | ObjectsFormView, 16 | ObjectsView, 17 | TemplateView, 18 | UpdateView, 19 | View, 20 | ) 21 | -------------------------------------------------------------------------------- /src/crudlfap/views/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django import http 4 | from crudlfap.views.generic import View 5 | 6 | 7 | schema = { 8 | 'basePath': '/v2', 9 | 'definitions': {}, 10 | 'info': {}, 11 | 'paths': {}, 12 | 'schemes': ['https', 'http'], 13 | 'securityDefinitions': {}, 14 | 'swagger': '2.0', 15 | 'tags': [] 16 | } 17 | 18 | 19 | class SchemaView(View): 20 | authenticate = False 21 | 22 | def get(self, request, *args, **kwargs): 23 | schema['definitions'] = dict() 24 | schema['paths'] = dict() 25 | schema['host'] = request.get_host() 26 | for model, router in self.registry.items(): 27 | for view in router.views: 28 | view = view.abstract(request=request) 29 | if not view.has_perm(): 30 | continue 31 | if getattr(view, 'router', None): 32 | self.add_router_schema(request, view.router, schema) 33 | try: 34 | path_definition = view.swagger_path_definition 35 | except AttributeError: 36 | pass 37 | else: 38 | if path_definition: 39 | self.add_path_definition(path_definition, view, schema) 40 | return http.JsonResponse(schema) 41 | 42 | def add_path_definition(self, path_definition, view, schema): 43 | url = view.urlpath 44 | if router := getattr(view, 'router', None): 45 | if url: 46 | url = os.path.join(router.urlpath, url) 47 | else: 48 | url = router.urlpath 49 | if self.registry.urlpath: 50 | url = self.registry.urlpath + url 51 | url = '/' + url.replace('<', '{').replace('>', '}') 52 | schema['paths'][url] = path_definition 53 | 54 | def add_router_schema(self, request, router, schema): 55 | try: 56 | model_name = router.get_swagger_model_name(request) 57 | except AttributeError: 58 | pass 59 | else: 60 | if model_name and model_name not in schema['definitions']: 61 | model_definition = router.get_swagger_model_definition(request) 62 | if model_definition: 63 | schema['definitions'][model_name] = model_definition 64 | -------------------------------------------------------------------------------- /src/crudlfap/views/generic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Crudlfa+ generic views and mixins. 3 | 4 | Crudlfa+ takes views further than Django and are expected to: 5 | 6 | - generate their URL definitions and reversions, 7 | - check if a user has permission for an object, 8 | - declare the names of the navigation menus they belong to. 9 | """ 10 | from django import http 11 | from django.conf import settings 12 | from django.views import generic 13 | 14 | from .. import mixins 15 | from ..route import Route 16 | 17 | if 'django.contrib.admin' in settings.INSTALLED_APPS: 18 | from django.contrib.admin.models import LogEntry 19 | else: 20 | LogEntry = None 21 | 22 | 23 | class View(mixins.LockMixin, mixins.MenuMixin, Route, generic.View): 24 | """Base view for CRUDLFA+.""" 25 | 26 | 27 | class TemplateView(mixins.TemplateMixin, View): 28 | """TemplateView for CRUDLFA+.""" 29 | 30 | 31 | class ModelView(mixins.ModelMixin, TemplateView): 32 | pass 33 | 34 | 35 | class ObjectFormView(mixins.ObjectFormMixin, TemplateView): 36 | """Custom form view on an object.""" 37 | 38 | 39 | class ObjectView(mixins.ObjectMixin, TemplateView): 40 | pass 41 | 42 | 43 | class ObjectsFormView(mixins.ObjectsFormMixin, TemplateView): 44 | pass 45 | 46 | 47 | class ObjectsView(mixins.ObjectsMixin, TemplateView): 48 | pass 49 | 50 | 51 | class FormView(mixins.FormMixin, TemplateView): 52 | """Base FormView class.""" 53 | 54 | style = 'warning' 55 | default_template_name = 'crudlfap/form.html' 56 | 57 | 58 | class ModelFormView(mixins.ModelFormMixin, FormView): 59 | pass 60 | 61 | 62 | class CreateView(mixins.CreateMixin, ModelFormView): 63 | """View to create a model object.""" 64 | 65 | 66 | class DeleteView(mixins.DeleteMixin, ObjectFormView): 67 | """View to delete an object.""" 68 | 69 | 70 | class DeleteObjectsView(mixins.DeleteMixin, ObjectsFormView): 71 | """Delete selected objects.""" 72 | 73 | 74 | class DetailView(mixins.DetailMixin, ObjectView): 75 | """Templated model object detail view which takes a field option.""" 76 | 77 | def get_swagger_summary(self): 78 | return f'{self.model.__name__} Detail' 79 | 80 | def get_swagger_get(self): 81 | result = { 82 | 'operationId': f'find{self.model.__name__}ByStatus', 83 | 'produces': ['application/json'], 84 | 'responses': { 85 | '200': { 86 | 'description': 'successful operation', 87 | 'schema': { 88 | 'items': { 89 | '$ref': f'#/definitions/{self.model.__name__}' 90 | }, 91 | 'type': 'array' 92 | } 93 | }, 94 | '404': { 95 | 'description': 'Not found' 96 | }, 97 | }, 98 | 'summary': self.swagger_summary, 99 | 'tags': self.swagger_tags, 100 | } 101 | return result 102 | 103 | 104 | class HistoryView(mixins.ObjectMixin, generic.DetailView): 105 | pass 106 | 107 | 108 | class ListView(mixins.ListMixin, mixins.SearchMixin, mixins.FilterMixin, 109 | mixins.TableMixin, mixins.ObjectsMixin, TemplateView): 110 | 111 | def get_object_list(self): 112 | if self.filterset: 113 | self.object_list = self.filterset.qs 114 | else: 115 | self.object_list = self.queryset 116 | 117 | if self.search_form: 118 | self.object_list = self.search_filter(self.object_list) 119 | 120 | return self.object_list 121 | 122 | def get_listactions(self): 123 | return self.router.get_menu('list_action', self.request) 124 | 125 | def get_swagger_get(self): 126 | parameters = [] 127 | if self.filterset: 128 | for name, field in self.filterset.form.fields.items(): 129 | parameters.append({ 130 | 'collectionFormat': 'single', 131 | 'description': field.help_text, 132 | 'in': 'query', 133 | 'name': name, 134 | 'required': field.required, 135 | 'type': 'string' 136 | }) 137 | 138 | return { 139 | # 'description': 'Multiple status are comma separated', 140 | 'operationId': f'find{self.model.__name__}ByStatus', 141 | 'parameters': parameters, 142 | 'produces': ['application/json'], 143 | 'responses': { 144 | '200': { 145 | 'description': 'successful operation', 146 | 'schema': { 147 | 'items': { 148 | '$ref': f'#/definitions/{self.model.__name__}' 149 | }, 150 | 'type': 'array' 151 | } 152 | }, 153 | '400': {'description': 'Invalid parameter'} 154 | }, 155 | 'summary': self.title, 156 | 'tags': self.swagger_tags, 157 | } 158 | 159 | def get_json_fields(self): 160 | return self.router.json_fields 161 | 162 | def serialize(self, obj): 163 | if self.router: 164 | return self.router.serialize(obj, self.json_fields) 165 | return { 166 | field: getattr( 167 | self, 168 | f'get_{field}_json', 169 | 'get_FIELD_json', 170 | )(obj, field) 171 | for field in self.json_fields 172 | } 173 | 174 | def json_get(self, request, *args, **kwargs): 175 | rows = [] 176 | for row in self.table.paginated_rows: 177 | rows.append(self.serialize(row.record)) 178 | data = dict( 179 | results=rows, 180 | paginator=dict( 181 | page_number=self.table.page.number, 182 | per_page=self.table.page.paginator.per_page, 183 | total_pages=self.table.page.paginator.num_pages, 184 | total_objects=self.table.page.paginator.count, 185 | has_next=self.table.page.has_next(), 186 | has_previous=self.table.page.has_previous(), 187 | ) 188 | ) 189 | return http.JsonResponse(data) 190 | 191 | 192 | class UpdateView(mixins.UpdateMixin, ObjectFormView): 193 | """Model update view.""" 194 | -------------------------------------------------------------------------------- /src/crudlfap_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_auth/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_auth/backends.py: -------------------------------------------------------------------------------- 1 | class ViewBackend: 2 | def authenticate(self, *args): 3 | """ 4 | Always return ``None`` to prevent authentication within this backend. 5 | """ 6 | return None 7 | 8 | def has_perm(self, user_obj, perm, obj=None): # noqa 9 | try: 10 | if not obj.authenticate: 11 | return True 12 | except AttributeError: 13 | return False 14 | 15 | if not user_obj.is_authenticated: 16 | return False 17 | 18 | if user_obj.is_superuser: 19 | return True 20 | 21 | try: 22 | if obj.allowed_groups == 'any': 23 | return True 24 | except AttributeError: 25 | return False 26 | 27 | for group in user_obj.groups.all(): 28 | if group.name in obj.allowed_groups: 29 | return True 30 | 31 | return False 32 | -------------------------------------------------------------------------------- /src/crudlfap_auth/crudlfap.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.contrib.auth.models import Group 4 | from django.urls import reverse 5 | 6 | from crudlfap import shortcuts as crudlfap 7 | from crudlfap import html 8 | 9 | from . import views 10 | 11 | User = apps.get_model(getattr(settings, 'AUTH_USER_MODEL', 'auth.User')) 12 | 13 | 14 | def login_logout(request, menu): 15 | if request.user.is_authenticated: 16 | menu.append(html.MDCListItem( 17 | 'Logout', 18 | icon='logout', 19 | href=reverse('logout'), 20 | style='text-decoration: none', 21 | up_target='body', 22 | up_method='POST', 23 | up_follow=True, 24 | tag='a', 25 | )) 26 | else: 27 | menu.insert(1, html.MDCListItem( 28 | 'Login', 29 | icon='login', 30 | href=reverse('login'), 31 | style='text-decoration: none', 32 | up_target=html.UNPOLY_TARGET_ALL, 33 | tag='a', 34 | )) 35 | return menu 36 | 37 | 38 | html.mdcDrawer.menu_hooks.append(login_logout) 39 | 40 | 41 | crudlfap.Router( 42 | User, 43 | views=[ 44 | crudlfap.DeleteObjectsView, 45 | crudlfap.DeleteView, 46 | crudlfap.UpdateView.clone( 47 | fields=[ 48 | 'username', 49 | 'email', 50 | 'first_name', 51 | 'last_name', 52 | 'groups', 53 | 'is_superuser', 54 | 'is_staff', 55 | 'is_active', 56 | ] 57 | ), 58 | crudlfap.CreateView.clone( 59 | fields=[ 60 | 'username', 61 | 'email', 62 | 'groups', 63 | 'is_staff', 64 | 'is_superuser' 65 | ], 66 | ), 67 | views.PasswordView, 68 | views.BecomeUser, 69 | crudlfap.DetailView.clone(exclude=['password']), 70 | crudlfap.ListView.clone( 71 | search_fields=[ 72 | 'username', 73 | 'email', 74 | 'first_name', 75 | 'last_name' 76 | ], 77 | table_fields=[ 78 | 'username', 79 | 'email', 80 | 'first_name', 81 | 'last_name', 82 | 'is_staff', 83 | 'is_superuser' 84 | ], 85 | filter_fields=[ 86 | 'groups', 87 | 'is_superuser', 88 | 'is_staff' 89 | ], 90 | ), 91 | ], 92 | urlfield='username', 93 | icon='person', 94 | ).register() 95 | 96 | 97 | class GroupUpdateView(crudlfap.UpdateView): 98 | def get_form_class(self): 99 | cls = super().get_form_class() 100 | cls.base_fields['permissions'].queryset = ( 101 | cls.base_fields['permissions'].queryset.select_related( 102 | 'content_type')) 103 | return cls 104 | 105 | 106 | crudlfap.Router( 107 | Group, 108 | fields=['name', 'permissions'], 109 | icon='group', 110 | views=[ 111 | crudlfap.DeleteObjectsView, 112 | crudlfap.DeleteView, 113 | GroupUpdateView, 114 | crudlfap.CreateView, 115 | crudlfap.DetailView, 116 | crudlfap.ListView, 117 | ], 118 | ).register() 119 | 120 | crudlfap.site.views.append(views.Become.clone(model=User)) 121 | -------------------------------------------------------------------------------- /src/crudlfap_auth/html.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.urls import reverse 3 | from django.contrib.sites.models import Site 4 | from crudlfap.html import * # noqa 5 | 6 | User = apps.get_model(getattr(settings, 'AUTH_USER_MODEL', 'auth.User')) 7 | 8 | 9 | @template('registration/login.html', App) 10 | class LoginFormViewComponent(FormContainer): 11 | demo = False 12 | 13 | def context(self, *content, **context): 14 | context['view'].title = 'Login' 15 | return super().context(*content, **context) 16 | 17 | def to_html(self, *content, view, form, **kwargs): 18 | site = Site.objects.get_current(view.request) 19 | content = content or [ 20 | H2('Welcome to ' + site.name, style='text-align: center;'), 21 | ] 22 | return super().to_html( 23 | Form( 24 | *content, 25 | # OAuthConnect(), 26 | # Span('Or enter email and password:', cls='center-text'), 27 | CSRFInput(view.request), 28 | form, 29 | Div( 30 | MDCButtonRaised('Continue'), 31 | A( 32 | MDCButtonOutlined('forgot password'), 33 | href=reverse('password_reset') 34 | ), 35 | style='display: flex; justify-content: space-between', 36 | ), 37 | method='POST', 38 | action=view.request.path_info, 39 | cls='form card', 40 | up_target=None, 41 | up_layer=None, 42 | ), 43 | cls='', 44 | ) 45 | 46 | 47 | @template('registration/logged_out.html', App) 48 | class LogoutViewComponent(FormContainer): 49 | def context(self, *content, **context): 50 | context['view'].title = 'Logout' 51 | return super().context(*content, **context) 52 | 53 | def __init__(self, *content, **context): 54 | super().__init__( 55 | H4('You have been logged out'), 56 | Section( 57 | 'Thank you for spending time on our site today.', 58 | ), 59 | Div( 60 | MDCButton('Login again', tag='a', href=reverse('login')), 61 | style='display:flex; justify-content: flex-end;', 62 | ), 63 | cls='card', 64 | style='text-align: center', 65 | ) 66 | 67 | 68 | @template('registration/password_reset_form.html', App) 69 | class PasswordResetCard(FormContainer): 70 | def to_html(self, *content, view, form, **context): 71 | return super().to_html( 72 | H4('Reset your password', style='text-align: center;'), 73 | Form( 74 | CSRFInput(view.request), 75 | form, 76 | MDCButtonRaised('Reset password'), 77 | method='POST', 78 | cls='form', 79 | ), 80 | cls='card', 81 | ) 82 | 83 | 84 | @template('registration/password_reset_confirm.html', App) 85 | class PasswordResetConfirm(FormContainer): 86 | def context(self, *content, **context): 87 | context['view'].title = 'Password reset confirm' 88 | return super().context(*content, **context) 89 | 90 | def to_html(self, *content, view, form, **context): 91 | return super().to_html( 92 | H4('Reset your password', style='text-align: center;'), 93 | Form( 94 | CSRFInput(view.request), 95 | form, 96 | MDCButtonRaised('confirm'), 97 | method='POST', 98 | cls='form', 99 | ), 100 | cls='card', 101 | ) 102 | 103 | 104 | @template('registration/password_reset_complete.html', App) 105 | class PasswordResetComplete(FormContainer): 106 | def __init__(self, **context): 107 | super().__init__( 108 | H4('Your password have been reset', cls='center-text'), 109 | Div( 110 | 'You may go ahead and ', 111 | A('log in', href=reverse('login')), 112 | ' now', 113 | ), 114 | ) 115 | 116 | 117 | @template('registration/password_reset_done.html', App) 118 | class PasswordResetDone(FormContainer): 119 | def __init__(self, **context): 120 | super().__init__( 121 | H4('A link has been sent to your email address'), 122 | A('Go to login page', href=reverse('login')), 123 | cls='card', 124 | style='text-align: center;', 125 | ) 126 | -------------------------------------------------------------------------------- /src/crudlfap_auth/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-11-14 12:11+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: src/crudlfap_auth/views.py:33 22 | msgid "update" 23 | msgstr "" 24 | 25 | #: src/crudlfap_auth/views.py:57 26 | msgid "become" 27 | msgstr "devenir" 28 | 29 | #: src/crudlfap_auth/views.py:82 30 | msgid "Switched to user %s" 31 | msgstr "Vous usurpez maintenant: %s" 32 | 33 | #: src/crudlfap_auth/views.py:110 34 | msgid "Switched back to your user %s" 35 | msgstr "De retour sur votre utilisateur %s" 36 | 37 | #: src/crudlfap_auth/views.py:117 38 | msgid "You are still superuser %s" 39 | msgstr "Vous êtes toujours le superutilisateur %s" 40 | -------------------------------------------------------------------------------- /src/crudlfap_auth/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_auth/models.py -------------------------------------------------------------------------------- /src/crudlfap_auth/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Crudlfa+ PasswordView, Become and BecomeUser views. 3 | 4 | Crudlfa+ takes views further than Django and are expected to: 5 | 6 | - generate their URL definitions and reversions, 7 | - check if a user has permission for an object, 8 | - declare the names of the navigation menus they belong to. 9 | """ 10 | 11 | import logging 12 | 13 | from django import http 14 | from django.contrib import auth, messages 15 | from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm 16 | from django.utils.translation import gettext_lazy as _ 17 | 18 | from crudlfap import shortcuts as crudlfap 19 | 20 | logger = logging.getLogger() 21 | 22 | 23 | class PasswordView(crudlfap.UpdateView): 24 | slug = 'password' 25 | icon = 'vpn_key' 26 | color = 'purple' 27 | controller = 'modal' 28 | action = 'click->modal#open' 29 | 30 | def get_title_submit(self): 31 | return _('update').capitalize() 32 | 33 | def get_form_class(self): 34 | if self.object == self.request.user: 35 | cls = PasswordChangeForm 36 | else: 37 | cls = SetPasswordForm 38 | # This fixes the form messages feature from UpdateView 39 | return type(cls.__name__, (cls,), dict(instance=self.object)) 40 | 41 | def get_form_kwargs(self): 42 | kwargs = super().get_form_kwargs() 43 | kwargs['user'] = self.object 44 | return kwargs 45 | 46 | 47 | class BecomeUser(crudlfap.ObjectView): 48 | urlname = 'su' 49 | menus = ['object'] 50 | icon = 'accessibility_new' 51 | color = 'purple' 52 | link_attributes = {'data-noprefetch': 'true', 'up-target': 'body'} 53 | 54 | def become(self): 55 | """ 56 | Implement this method to override the default become logic. 57 | """ 58 | auth.login(self.request, self.object, backend=self.backend) 59 | 60 | def get_title_menu(self): 61 | return _('become').capitalize() 62 | 63 | def get_object(self, queryset=None): 64 | user = super().get_object() 65 | 66 | if user: 67 | user.backend = self.backend 68 | else: 69 | messages.error( 70 | self.request, 71 | 'Could not find user {}'.format(self.kwargs['username']) 72 | ) 73 | 74 | return user 75 | 76 | def get(self, request, *a, **k): 77 | logger.info('BecomeUser by {}'.format(self.request.user)) 78 | request_user = request.user 79 | self.request = request 80 | result = self.become() 81 | if 'become_user' not in request.session: 82 | request.session['become_user_realname'] = str(request_user) 83 | request.session['become_user'] = str(request_user.pk) 84 | messages.info( 85 | request, 86 | _('Switched to user %s') % request.user 87 | ) 88 | return result if result else http.HttpResponseRedirect( 89 | '/' + self.router.registry.urlpath) 90 | 91 | def get_backend(self): 92 | return 'django.contrib.auth.backends.ModelBackend' 93 | 94 | 95 | class Become(crudlfap.View): 96 | urlname = 'su' 97 | 98 | def has_perm(self): 99 | return 'become_user' in self.request.session 100 | 101 | def get_backend(self): 102 | return 'django.contrib.auth.backends.ModelBackend' 103 | 104 | def get_object(self): 105 | user = self.model.objects.get(pk=self.request.session['become_user']) 106 | user.backend = self.backend 107 | return user 108 | 109 | def get(self, request, *a, **k): 110 | logger.info('Become by {}'.format(self.request.user)) 111 | if 'become_user' not in request.session: 112 | logger.debug( 113 | 'No become_user in session {}'.format(self.request.user)) 114 | return http.HttpResponseNotFound() 115 | 116 | user = self.get_object() 117 | auth.login(request, user) 118 | messages.info( 119 | request, 120 | _('Switched back to your user %s') % user 121 | ) 122 | return http.HttpResponseRedirect('/' + self.registry.urlpath) 123 | -------------------------------------------------------------------------------- /src/crudlfap_example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/artist/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/artist/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/artist/crudlfap.py: -------------------------------------------------------------------------------- 1 | from crudlfap import shortcuts as crudlfap 2 | 3 | from .models import Artist 4 | 5 | crudlfap.Router( 6 | Artist, 7 | fields='__all__', 8 | icon='record_voice_over', 9 | ).register() 10 | -------------------------------------------------------------------------------- /src/crudlfap_example/artist/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.10 on 2017-09-17 22:09 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Artist', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=100)), 21 | ], 22 | options={ 23 | 'ordering': ('name',), 24 | }, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /src/crudlfap_example/artist/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/artist/migrations/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/artist/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Artist(models.Model): 5 | name = models.CharField(max_length=100) 6 | 7 | class Meta: 8 | ordering = ('name',) 9 | 10 | def __str__(self): 11 | return self.name 12 | -------------------------------------------------------------------------------- /src/crudlfap_example/artist/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from crudlfap_auth.crudlfap import User 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_api(client): 9 | user = User.objects.create(is_superuser=True, is_active=True) 10 | client.force_login(user) 11 | 12 | # test valid form 13 | response = client.post( 14 | '/artist/create', 15 | json.dumps(dict( 16 | name='test artist', 17 | )), 18 | content_type='application/json' 19 | ) 20 | assert response.status_code == 201 21 | 22 | # test invalid form 23 | response = client.post( 24 | '/artist/create', 25 | json.dumps(dict( 26 | name='', 27 | )), 28 | content_type='application/json' 29 | ) 30 | assert response.status_code == 405 31 | 32 | # test list get 33 | response = client.get( 34 | '/artist', 35 | HTTP_ACCEPT='application/json', 36 | ) 37 | assert response.status_code == 200 38 | data = response.json() 39 | assert len(data['results']) == 1 40 | assert data['results'][0]['name'] == 'test artist' 41 | response = client.get( 42 | f'/artist/{data["results"][0]["id"]}', 43 | HTTP_ACCEPT='application/json' 44 | ) 45 | assert response.json() == {'id': 1, 'name': 'test artist'} 46 | -------------------------------------------------------------------------------- /src/crudlfap_example/blog/crudlfap.py: -------------------------------------------------------------------------------- 1 | from crudlfap import shortcuts as crudlfap 2 | 3 | from .models import Post 4 | 5 | 6 | class AuthBackend: 7 | def authenticate(self, *args): 8 | return None # prevent auth from this backend 9 | 10 | def has_perm(self, user_obj, perm, obj=None): 11 | view = obj 12 | 13 | if view.model != Post: 14 | return False 15 | 16 | user = user_obj 17 | code = view.permission_shortcode 18 | 19 | if code in ('list', 'detail'): 20 | return True 21 | elif code == 'add': 22 | return user.is_authenticated 23 | elif code == 'change': 24 | return view.object.editable(user) 25 | elif code == 'delete': 26 | if hasattr(view, 'object'): 27 | return view.object.editable(user) 28 | 29 | # DeleteObjects relies on get_queryset to secure runtime 30 | return user.is_authenticated 31 | 32 | return super().has_perm(user_obj, perm, obj) 33 | 34 | 35 | class PostMixin: 36 | def get_exclude(self): 37 | if not self.request.user.is_staff: 38 | return ['owner'] 39 | return super().get_exclude() 40 | 41 | 42 | class PostCreateView(PostMixin, crudlfap.CreateView): 43 | def form_valid(self): 44 | self.form.instance.owner = self.request.user 45 | return super().form_valid() 46 | 47 | 48 | class PostUpdateView(PostMixin, crudlfap.UpdateView): 49 | pass 50 | 51 | 52 | class PostListView(crudlfap.ListView): 53 | def get_filter_fields(self): 54 | if self.request.user.is_staff: 55 | return ['owner'] 56 | return [] 57 | 58 | 59 | class PostRouter(crudlfap.Router): 60 | fields = '__all__' 61 | icon = 'book' 62 | model = Post 63 | 64 | views = [ 65 | crudlfap.DeleteObjectsView, 66 | crudlfap.DeleteView, 67 | PostUpdateView, 68 | PostCreateView, 69 | crudlfap.DetailView, 70 | PostListView.clone( 71 | search_fields=['name'], 72 | ), 73 | ] 74 | 75 | def get_queryset(self, view): 76 | qs = self.model.objects.get_queryset() 77 | if view.request.user.is_superuser: 78 | return qs 79 | elif view.permission_shortcode in ('change', 'delete'): 80 | return qs.editable(view.request.user) 81 | elif view.permission_shortcode in ('list', 'detail'): 82 | return qs.readable(view.request.user) 83 | return qs.none() 84 | 85 | 86 | PostRouter().register() 87 | -------------------------------------------------------------------------------- /src/crudlfap_example/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-15 23:59 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Post', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=100, verbose_name='title')), 23 | ('publish', models.DateTimeField(default=django.utils.timezone.now)), 24 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | options={ 27 | 'ordering': ('name',), 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /src/crudlfap_example/blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/blog/migrations/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | 6 | class PostQuerySet(models.QuerySet): 7 | def readable(self, user): 8 | if user.is_staff or user.is_superuser: 9 | return self 10 | 11 | published = models.Q(publish__lte=timezone.now()) 12 | 13 | if not user.is_authenticated: 14 | return self.filter(published) 15 | 16 | return self.filter(models.Q(owner=user) | published) 17 | 18 | def editable(self, user): 19 | if not user.is_authenticated: 20 | return self.none() 21 | 22 | if user.is_staff or user.is_superuser: 23 | return self 24 | 25 | return self.filter(owner=user) 26 | 27 | 28 | class PostManager(models.Manager): 29 | def get_queryset(self): 30 | return PostQuerySet(self.model, using=self._db) 31 | 32 | 33 | class Post(models.Model): 34 | name = models.CharField(max_length=100, verbose_name='title') 35 | publish = models.DateTimeField(default=timezone.now) 36 | owner = models.ForeignKey( 37 | getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), 38 | on_delete=models.CASCADE, 39 | ) 40 | 41 | objects = PostManager() 42 | 43 | class Meta: 44 | ordering = ('name',) 45 | 46 | def __str__(self): 47 | return self.name 48 | 49 | def editable(self, user): 50 | return user.is_staff or user.is_superuser or user.pk == self.owner_id 51 | -------------------------------------------------------------------------------- /src/crudlfap_example/blog/test_security.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test security with crudlfap_example.song setup. 3 | 4 | It's a bit paranoid, but i'll sleep better with this: i don't trust users, i 5 | don't trust myself either :) 6 | """ 7 | from datetime import timedelta 8 | 9 | import pytest 10 | from django.utils import timezone 11 | 12 | from crudlfap import shortcuts as crudlfap 13 | from crudlfap_auth.crudlfap import User 14 | from crudlfap_example.blog.models import Post 15 | 16 | 17 | def user(**attrs): 18 | user, created = User.objects.get_or_create(**attrs) 19 | if created: 20 | user.set_password('password') 21 | user.save() 22 | return user 23 | 24 | 25 | def user_client(client, **userattrs): 26 | if not userattrs.get('username', None): 27 | return client 28 | user(**userattrs) 29 | if not client.login(username=userattrs['username'], password='password'): 30 | raise Exception('Could not auth as ' + userattrs['username']) 31 | return client 32 | 33 | 34 | router = pytest.fixture(lambda: crudlfap.site[Post]) 35 | now = timezone.now() 36 | yesterday = now - timedelta(days=1) 37 | tomorrow = now + timedelta(days=1) 38 | 39 | user0 = pytest.fixture(lambda: user(username='user0')) 40 | user1 = pytest.fixture(lambda: user(username='user1')) 41 | staff = pytest.fixture(lambda: user(username='staff', is_staff=True)) 42 | 43 | post0 = pytest.fixture(lambda user0: Post.objects.get_or_create( 44 | owner=user0, name='post0', publish=yesterday)[0]) 45 | post1 = pytest.fixture(lambda user0: Post.objects.get_or_create( 46 | owner=user0, name='post1', publish=tomorrow)[0]) 47 | post2 = pytest.fixture(lambda user1: Post.objects.get_or_create( 48 | owner=user1, name='post2', publish=yesterday)[0]) 49 | 50 | 51 | def url(name, obj): 52 | return crudlfap.site[Post][name].clone(object=obj).url 53 | 54 | 55 | @pytest.mark.django_db 56 | def test_list_view(router, client, user0, user1, post0, post1, post2): 57 | list_url = router['list'].url 58 | res = user_client(client, username='user0').get(list_url) 59 | assert b'post0' in res.content 60 | assert b'post1' in res.content 61 | assert b'post2' in res.content 62 | for name in ('update', 'delete'): 63 | view = router[name] 64 | assert str(view.clone(object=post0).url) in str(res.content) 65 | assert str(view.clone(object=post1).url) in str(res.content) 66 | assert str(view.clone(object=post2).url) not in str(res.content) 67 | 68 | res = user_client(client, username='user1').get(list_url) 69 | assert b'post0' in res.content 70 | assert b'post1' not in res.content 71 | assert b'post2' in res.content 72 | for name in ('update', 'delete'): 73 | view = router[name] 74 | assert str(view.clone(object=post0).url) not in str(res.content) 75 | assert str(view.clone(object=post1).url) not in str(res.content) 76 | assert str(view.clone(object=post2).url) in str(res.content) 77 | 78 | 79 | @pytest.mark.django_db 80 | def test_anonymous_detail_published(router, client, post0, post1): 81 | assert client.get(url('detail', post0)).status_code == 200 82 | assert client.get(url('detail', post1)).status_code == 404 83 | 84 | 85 | @pytest.mark.django_db 86 | def test_owner_detail_unpublished(router, client, post0, post1): 87 | client = user_client(client, username='user0') 88 | assert client.get(url('detail', post0)).status_code == 200 89 | assert client.get(url('detail', post1)).status_code == 200 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_nonowner_detail_unpublished(router, client, post0, post1): 94 | client = user_client(client, username='user1') 95 | assert client.get(url('detail', post0)).status_code == 200 96 | assert client.get(url('detail', post1)).status_code == 404 97 | 98 | 99 | @pytest.mark.parametrize('name', ['delete', 'update']) 100 | @pytest.mark.django_db 101 | def test_anonymous_edit_published(router, client, post0, post1, name): 102 | assert client.get(url(name, post0)).status_code == 404 103 | assert client.get(url(name, post1)).status_code == 404 104 | 105 | 106 | @pytest.mark.parametrize('name', ['delete', 'update']) 107 | @pytest.mark.django_db 108 | def test_owner_edits(router, client, post0, post1, post2, name): 109 | client = user_client(client, username='user0') 110 | assert client.get(url(name, post0)).status_code == 200 111 | assert client.get(url(name, post1)).status_code == 200 112 | assert client.get(url(name, post2)).status_code == 404 113 | 114 | client = user_client(client, username='user1') 115 | assert client.get(url(name, post0)).status_code == 404 116 | assert client.get(url(name, post1)).status_code == 404 117 | assert client.get(url(name, post2)).status_code == 200 118 | -------------------------------------------------------------------------------- /src/crudlfap_example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from crudlfap.manage import main 3 | 4 | if __name__ == '__main__': 5 | main('crudlfap_example.settings') 6 | -------------------------------------------------------------------------------- /src/crudlfap_example/settings.py: -------------------------------------------------------------------------------- 1 | from crudlfap.settings import * # noqa 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent 4 | 5 | INSTALLED_APPS += [ # noqa 6 | # CRUDLFA+ extras 7 | 'django_registration', 8 | 'crudlfap_registration', 9 | 10 | # CRUDLFA+ examples 11 | 'crudlfap_example.artist', 12 | 'crudlfap_example.song', 13 | 'crudlfap_example.blog', 14 | ] 15 | 16 | ROOT_URLCONF = os.path.dirname(__file__).split('/')[-1] + '.urls' 17 | 18 | DATABASES = { 19 | 'default': { 20 | 'ENGINE': os.getenv('DB_ENGINE', 'django.db.backends.sqlite3'), 21 | 'NAME': os.getenv('DB_NAME', os.path.join( 22 | os.path.dirname(__file__), 23 | '..', 24 | 'db.sqlite3', 25 | )), 26 | 'HOST': os.getenv('DB_HOST'), 27 | 'USER': os.getenv('DB_USER'), 28 | 'PASSWORD': os.getenv('DB_PASS'), 29 | } 30 | } 31 | 32 | install_optional(OPTIONAL_APPS, INSTALLED_APPS) # noqa 33 | install_optional(OPTIONAL_MIDDLEWARE, MIDDLEWARE) # noqa 34 | 35 | AUTHENTICATION_BACKENDS += [ # noqa 36 | 'crudlfap_example.blog.crudlfap.AuthBackend', 37 | ] 38 | 39 | LOGGING = { 40 | 'version': 1, 41 | 'disable_existing_loggers': False, 42 | 'formatters': { 43 | 'verbose': { 44 | 'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s', 45 | }, 46 | }, 47 | 'handlers': { 48 | 'console': { 49 | 'level': 'DEBUG', 50 | 'class': 'logging.StreamHandler', 51 | 'stream': sys.stdout, 52 | 'formatter': 'verbose' 53 | }, 54 | }, 55 | 'loggers': { 56 | '': { 57 | 'handlers': ['console'], 58 | 'level': 'DEBUG', 59 | 'propagate': True, 60 | }, 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /src/crudlfap_example/song/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/song/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/song/crudlfap.py: -------------------------------------------------------------------------------- 1 | from crudlfap import shortcuts as crudlfap 2 | 3 | from .models import Song 4 | 5 | 6 | class SongMixin: 7 | allowed_groups = 'any' 8 | 9 | def get_exclude(self): 10 | if not self.request.user.is_staff: 11 | return ['owner'] 12 | return super().get_exclude() 13 | 14 | 15 | class SongCreateView(SongMixin, crudlfap.CreateView): 16 | def form_valid(self): 17 | self.form.instance.owner = self.request.user 18 | return super().form_valid() 19 | 20 | 21 | class SongRouter(crudlfap.Router): 22 | fields = '__all__' 23 | icon = 'album' 24 | model = Song 25 | 26 | views = [ 27 | crudlfap.DeleteView.clone(SongMixin), 28 | crudlfap.UpdateView.clone(SongMixin), 29 | SongCreateView, 30 | crudlfap.DetailView.clone( 31 | authenticate=False, 32 | ), 33 | crudlfap.ListView.clone( 34 | authenticate=False, 35 | filter_fields=['artist'], 36 | search_fields=['artist__name', 'name'], 37 | ), 38 | ] 39 | 40 | def get_queryset(self, view): 41 | user = view.request.user 42 | 43 | if user.is_staff or user.is_superuser: 44 | return self.model.objects.all() 45 | elif not user.is_authenticated: 46 | return self.model.objects.none() 47 | 48 | return self.model.objects.filter(owner=user) 49 | 50 | 51 | SongRouter().register() 52 | -------------------------------------------------------------------------------- /src/crudlfap_example/song/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-15 15:22 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('artist', '0001_initial'), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Song', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=100, verbose_name='title')), 23 | ('duration', models.IntegerField(default=320)), 24 | ('artist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='artist.Artist')), 25 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | options={ 28 | 'ordering': ('name',), 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /src/crudlfap_example/song/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_example/song/migrations/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_example/song/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class Song(models.Model): 7 | artist = models.ForeignKey('artist.artist', models.CASCADE) 8 | name = models.CharField(max_length=100, verbose_name=_('title')) 9 | duration = models.IntegerField(default=320) 10 | owner = models.ForeignKey( 11 | getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), 12 | on_delete=models.CASCADE, 13 | ) 14 | 15 | class Meta: 16 | ordering = ('name',) 17 | 18 | def __str__(self): 19 | return self.name 20 | -------------------------------------------------------------------------------- /src/crudlfap_example/song/test_security.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test security with crudlfap_example.song setup. 3 | 4 | It's a bit paranoid, but i'll sleep better with this: i don't trust users, i 5 | don't trust myself either :) 6 | """ 7 | 8 | import pytest 9 | 10 | from crudlfap import shortcuts as crudlfap 11 | from crudlfap_auth.crudlfap import User 12 | from crudlfap_example.artist.models import Artist 13 | from crudlfap_example.song.models import Song 14 | 15 | 16 | def user(**attrs): 17 | user, created = User.objects.get_or_create(**attrs) 18 | if created: 19 | user.set_password('password') 20 | user.save() 21 | return user 22 | 23 | 24 | def user_client(client, **userattrs): 25 | if not userattrs.get('username', None): 26 | return client 27 | user(**userattrs) 28 | if not client.login(username=userattrs['username'], password='password'): 29 | raise Exception('Could not auth as ' + userattrs['username']) 30 | return client 31 | 32 | 33 | @pytest.fixture 34 | def song0(): 35 | user0 = user(username='user0') 36 | artist = Artist.objects.get_or_create(name='artist0')[0] 37 | return Song.objects.get_or_create( 38 | artist=artist, name='song0', owner=user0)[0] 39 | 40 | 41 | cases = [ 42 | (dict(), False), 43 | (dict(username='user0'), True), 44 | (dict(username='user1'), False), 45 | (dict(username='staff', is_staff=True), True), 46 | (dict(username='superuser', is_superuser=True), True), 47 | ] 48 | 49 | 50 | @pytest.mark.parametrize('userattrs,expected', cases) 51 | @pytest.mark.parametrize('url', ['detail', 'update', 'delete']) 52 | @pytest.mark.django_db 53 | def test_object_views_object_for_user(client, userattrs, expected, url, song0): 54 | view = crudlfap.site[Song][url].clone(object=song0) 55 | url = view.url 56 | res = user_client(client, **userattrs).get(url) 57 | assert res.status_code == 200 if expected else 404 58 | 59 | 60 | @pytest.mark.parametrize('userattrs,expected', cases) 61 | @pytest.mark.django_db 62 | def test_list_view_get_objects_for_user(client, userattrs, expected, song0): 63 | res = user_client(client, **userattrs).get(crudlfap.site[Song]['list'].url) 64 | 65 | assert res.status_code == 200 if userattrs else 302 66 | 67 | if expected: 68 | assert b'song0' in res.content 69 | else: 70 | assert b'song0' not in res.content 71 | -------------------------------------------------------------------------------- /src/crudlfap_example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path, re_path 3 | 4 | from crudlfap import shortcuts as crudlfap 5 | 6 | urlpatterns = [ 7 | crudlfap.site.urlpattern, 8 | path('auth/', include('django.contrib.auth.urls')), 9 | path('bundles/', include('ryzom_django.bundle')), 10 | ] 11 | 12 | # CRUDLFA+ extras 13 | if 'crudlfap_registration' in settings.INSTALLED_APPS: 14 | urlpatterns.append( 15 | path( 16 | 'registration/', 17 | include('django_registration.backends.activation.urls') 18 | ), 19 | ) 20 | 21 | if 'debug_toolbar' in settings.INSTALLED_APPS and settings.DEBUG: 22 | import debug_toolbar 23 | urlpatterns += [ 24 | re_path(r'^__debug__/', include(debug_toolbar.urls)), 25 | ] 26 | 27 | # PUBLIC DEMO mode 28 | from django.contrib.auth.views import LoginView # noqa 29 | from django.contrib.auth.models import User # noqa 30 | 31 | 32 | def login_view(request, *args, **kwargs): 33 | if request.method == 'POST': 34 | for name in ('admin', 'staff', 'user'): 35 | user = User.objects.filter(username=name).first() 36 | if not user: 37 | user = User(username=name, email='user@example.com') 38 | if name == 'staff': 39 | user.is_staff = True 40 | if name == 'admin': 41 | user.is_superuser = True 42 | user.set_password(name) 43 | user.save() 44 | return LoginView.as_view()(request, *args, **kwargs) 45 | urlpatterns.insert(0, path('auth/login/', login_view, name='login')) # noqa 46 | 47 | 48 | from crudlfap_auth.html import * # noqa 49 | @template('registration/login.html', App) # noqa 50 | class DemoLogin(LoginFormViewComponent): 51 | def to_html(self, *content, **context): 52 | return super().to_html( 53 | H3('Demo mode enabled'), 54 | P('Login with either username/password of:'), 55 | Ul( 56 | Li('user/user: for user'), 57 | Li('staff/staff: for staff'), 58 | Li('admin/admin: for superuser (technical stuff should appear)'), # noqa 59 | ), 60 | *content, 61 | **context, 62 | ) 63 | -------------------------------------------------------------------------------- /src/crudlfap_example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for crudlfap_example 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/1.9/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", "crudlfap_example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/crudlfap_registration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_registration/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_registration/crudlfap.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from crudlfap import html 4 | 5 | 6 | def registration(request, menu): 7 | if not request.user.is_authenticated: 8 | menu.insert(1, html.A( 9 | html.MDCListItem('Signup', icon='badge'), 10 | href=reverse('django_registration_register'), 11 | style='text-decoration: none', 12 | )) 13 | return menu 14 | 15 | 16 | html.mdcDrawer.menu_hooks.append(registration) 17 | -------------------------------------------------------------------------------- /src/crudlfap_registration/html.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.urls import reverse 3 | from crudlfap.html import * # noqa 4 | 5 | User = apps.get_model(getattr(settings, 'AUTH_USER_MODEL', 'auth.User')) 6 | 7 | 8 | @template('django_registration/registration_form.html', App) 9 | class RegistrationFormViewComponent(FormContainer): 10 | def to_html(self, *content, view, form, **context): 11 | return super().to_html( 12 | *content, 13 | H1('Looks like you need to register', cls='center-text'), 14 | Form( 15 | CSRFInput(view.request), 16 | form, 17 | MDCButtonRaised('Register'), 18 | method='POST', 19 | cls='form card', 20 | ), 21 | ) 22 | 23 | 24 | @template('django_registration/registration_complete.html', App) 25 | class RegistrationCompleteNarrowCard(FormContainer): 26 | def __init__(self, **context): 27 | super().__init__( 28 | H4('Check your emails to finish !'), 29 | Div( 30 | 'An activation link has been sent to your email address, ' 31 | + 'please open it to finish the signup process.', 32 | style='margin-bottom: 24px', 33 | ), 34 | Div( 35 | 'Then, come back and login to participate to your election.', 36 | style='margin-bottom: 24px;', 37 | ), 38 | cls='card', 39 | style='text-align: center', 40 | ) 41 | 42 | 43 | @template('django_registration/activation_complete.html', App) 44 | class ActivationCompleteNarrowCard(FormContainer): 45 | def __init__(self, **context): 46 | super().__init__( 47 | H4('Your account has been activated !'), 48 | Div( 49 | 'You may now ', 50 | A('login', href=reverse('login')), 51 | style='margin-bottom: 24px', 52 | ), 53 | cls='card', 54 | style='text-align: center', 55 | ) 56 | 57 | 58 | @template('django_registration/activation_failed.html', App) 59 | class ActivationFailureNarrowCard(FormContainer): 60 | def __init__(self, **context): 61 | super().__init__( 62 | H4('Account activation failure'), 63 | Div( 64 | 'Most likely your account has already been activated.', 65 | style='margin-bottom: 24px', 66 | ), 67 | cls='card', 68 | style='text-align: center', 69 | ) 70 | -------------------------------------------------------------------------------- /src/crudlfap_registration/templates/django_registration/activation_email_body.txt: -------------------------------------------------------------------------------- 1 | Welcome on {{ site.name }} ! 2 | 3 | Click below to verify your email, you will then be able to login. 4 | 5 | {{ site.url }}{% url 'django_registration_activate' activation_key %} 6 | 7 | This email as been sended automatically. 8 | Please don't respond at this mail. 9 | Thank's. 10 | Team {{ site.name }}. 11 | -------------------------------------------------------------------------------- /src/crudlfap_registration/templates/django_registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | Activate your account on Electeez 2 | -------------------------------------------------------------------------------- /src/crudlfap_registration/test_registration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_registration(client, mailoutbox): 7 | assert len(mailoutbox) == 0 8 | response = client.post('/registration/register/', dict( 9 | email='testregistration@example.com', 10 | username='testregistration', 11 | password1='!@aoe@#$', 12 | password2='!@aoe@#$', 13 | )) 14 | assert response.status_code == 302 15 | assert len(mailoutbox) == 1 16 | match = re.search('http://testserver[^\n]*', mailoutbox[0].body) 17 | assert match 18 | activate = match.group() 19 | response = client.get(activate) 20 | assert response.status_code == 302 21 | assert response['Location'] == '/registration/activate/complete/' 22 | response = client.post('/auth/login/', dict( 23 | username='testregistration', 24 | password='!@aoe@#$', 25 | )) 26 | assert response.status_code == 302 27 | -------------------------------------------------------------------------------- /src/crudlfap_sites/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_sites/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_sites/crudlfap.py: -------------------------------------------------------------------------------- 1 | from crudlfap import shortcuts as crudlfap 2 | 3 | from .models import Site 4 | 5 | 6 | crudlfap.Router(model=Site, icon='language').register() 7 | -------------------------------------------------------------------------------- /src/crudlfap_sites/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-06 09:29 2 | 3 | import django.contrib.sites.models 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('sites', '0002_alter_domain_unique'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Site', 19 | fields=[ 20 | ('site_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sites.site')), 21 | ('settings', models.JSONField(blank=True, default={})), 22 | ('port', models.PositiveIntegerField(null=True)), 23 | ('protocol', models.CharField(default='http', max_length=5)), 24 | ], 25 | bases=('sites.site',), 26 | managers=[ 27 | ('objects', django.contrib.sites.models.SiteManager()), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /src/crudlfap_sites/migrations/0002_alter_site_settings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-10 15:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('crudlfap_sites', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='site', 15 | name='settings', 16 | field=models.JSONField(blank=True, default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/crudlfap_sites/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourlabs/crudlfap/ae41ef6088be8a6937a950f57d6171df82165cb2/src/crudlfap_sites/migrations/__init__.py -------------------------------------------------------------------------------- /src/crudlfap_sites/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.models import Site as DjangoSite, SiteManager 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.db import models 4 | from django.http.request import split_domain_port 5 | 6 | 7 | class Site(DjangoSite): 8 | settings = models.JSONField(blank=True, default=dict) 9 | port = models.PositiveIntegerField(null=True) 10 | protocol = models.CharField(default='http', max_length=5) 11 | 12 | @property 13 | def url(self): 14 | return f'{self.protocol}://{self.domain}' 15 | 16 | 17 | old_get_current = SiteManager.get_current 18 | DjangoSite.objects.model = Site 19 | 20 | 21 | def get_current(self, request=None): 22 | try: 23 | return old_get_current(self, request) 24 | except (ImproperlyConfigured, Site.DoesNotExist): 25 | if not request: 26 | return Site(domain='localhost', name='localhost') 27 | host = request.get_host() 28 | domain, port = split_domain_port(host) 29 | protocol = request.META['wsgi.url_scheme'] 30 | Site.objects.create( 31 | name=domain.capitalize(), 32 | domain=host, 33 | port=port or 443 if protocol == 'https' else 80, 34 | protocol=protocol, 35 | ) 36 | return old_get_current(self, request) 37 | 38 | 39 | SiteManager.get_current = get_current 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39,310}-dj{20,21,30,31,40} 3 | 4 | [testenv] 5 | usedevelop = true 6 | 7 | commands = 8 | pip install -e {toxinidir}[project] 9 | pytest -vv --cov src --cov-report=xml:coverage.xml --cov-report=term-missing --strict -r fEsxXw {posargs:src} 10 | 11 | deps = 12 | pytest 13 | pytest-django 14 | pytest-cov 15 | pytest-mock 16 | dj20: Django>=2.0,<2.1 17 | dj21: Django>=2.1,<2.2 18 | dj30: Django>=3.0,<3.1 19 | dj31: Django>=3.1,<3.2 20 | dj40: Django>=4.0,<4.1 21 | 22 | setenv = 23 | DEBUG=1 24 | PIP_ALLOW_EXTERNAL=true 25 | 26 | [testenv:qa] 27 | commands = 28 | flake8 --show-source --max-complexity=8 --exclude migrations src/ --builtins=ModuleNotFoundError --ignore F405,W503 29 | deps = 30 | flake8 31 | mccabe 32 | 33 | [flake8] 34 | exclude = crudlfap_example 35 | putty-auto-ignore = true 36 | putty-ignore = 37 | crudlfap/shortcuts.py : F401 38 | crudlfap/test_routers.py : D 39 | 40 | [pytest] 41 | testpaths = src 42 | DJANGO_SETTINGS_MODULE = crudlfap_example.settings 43 | --------------------------------------------------------------------------------