commit 378d4692a9a3cd09220dafbd66718629dd7d5b42 Author: kusz Date: Wed Nov 27 13:18:16 2024 +0100 Initial commit from ZIP diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..0adb61a Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100755 index 0000000..3daeebb --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,151 @@ +stages: + - docker-build + - deploy + +build-web-release: + stage: docker-build + image: docker:latest + only: + - /^release/.*$/ + + variables: + DOCKER_DRIVER: "overlay2" + OUTBO_BACKEND_URL: "https://outbo-api.stage.ermlab.com" + + services: + - docker:dind + + before_script: + - docker version + - docker info + - docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY} + + script: + - docker pull ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_BEFORE_SHA} || true + - docker pull ${CI_REGISTRY_IMAGE}/web:latest || true + - docker build -f web.Dockerfile --build-arg outbo_env=staging --build-arg backend_url=https://outbo-api.stage.ermlab.com/ --cache-from ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_BEFORE_SHA} --cache-from ${CI_REGISTRY_IMAGE}/web:latest -t ${CI_REGISTRY_IMAGE}/web:latest -t ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} ./evg-outbo-web + - docker push ${CI_REGISTRY_IMAGE}/web:latest + - docker push ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} + + after_script: + - docker logout ${CI_REGISTRY} + +build-web-master: + stage: docker-build + image: docker:latest + only: + - master + + variables: + DOCKER_DRIVER: "overlay2" + + services: + - docker:dind + + before_script: + - docker version + - docker info + - docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY} + + script: + - docker pull ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_BEFORE_SHA} || true + - docker pull ${CI_REGISTRY_IMAGE}/web:latest || true + - docker build --build-arg outbo_env=production --build-arg backend_url=https://api.outbo.pl/ -f web.Dockerfile --cache-from ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_BEFORE_SHA} --cache-from ${CI_REGISTRY_IMAGE}/web:latest -t ${CI_REGISTRY_IMAGE}/web:latest -t ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} ./evg-outbo-web + - docker push ${CI_REGISTRY_IMAGE}/web:latest + - docker push ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} + + after_script: + - docker logout ${CI_REGISTRY} + +build-api: + stage: docker-build + image: docker:latest + only: + - /^release/.*$/ + - master + + variables: + DOCKER_DRIVER: "overlay2" + + services: + - docker:dind + + before_script: + - docker version + - docker info + - docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY} + + script: + - docker pull ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_BEFORE_SHA} || true + - docker pull ${CI_REGISTRY_IMAGE}/api:latest || true + - docker build -f api.Dockerfile --cache-from ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_BEFORE_SHA} --cache-from ${CI_REGISTRY_IMAGE}/api:latest -t ${CI_REGISTRY_IMAGE}/api:latest -t ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA} ./evg-outbo-api + - docker push ${CI_REGISTRY_IMAGE}/api:latest + - docker push ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA} + + after_script: + - docker logout ${CI_REGISTRY} + +deploy-staging: + stage: deploy + image: kroniak/ssh-client + only: + - /^release/.*$/ + services: + - docker:dind + + before_script: + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config + - echo "${STAGE_KEY}" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_ed25519 + - ssh-keyscan -H ${STAGE_HOST} >> ~/.ssh/known_hosts + script: + - scp -r ./docker-compose.staging.yml ${STAGE_USER}@${STAGE_HOST}:~/docker-compose/evg-outbo/docker-compose.yml + - > + ssh ${STAGE_USER}@${STAGE_HOST} << EOF + docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} + cd ~/docker-compose/evg-outbo/ + sed -i -e 's#__FRONTEND_IMAGE__#${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA}#' docker-compose.yml + sed -i -e 's#__BACKEND_IMAGE__#${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA}#' docker-compose.yml + docker pull ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA} + docker pull ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} + docker-compose rm --force backend + docker-compose rm --force frontend + docker-compose up -d backend + docker-compose up -d frontend + EOF + +deploy-production: + stage: deploy + image: kroniak/ssh-client + only: + - master + services: + - docker:dind + + before_script: + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config + - echo "${PROD_KEY}" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_ed25519 + - ssh-keyscan -H ${PROD_HOST} >> ~/.ssh/known_hosts + script: + - scp -r ./docker-compose.prod.yml ${PROD_USER}@${PROD_HOST}:~/docker-compose.yml + - > + ssh ${PROD_USER}@${PROD_HOST} << EOF + docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} + sed -i -e 's#COMPOSE_BACKEND_IMAGE=.*#COMPOSE_BACKEND_IMAGE=${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA}#' .env + sed -i -e 's#COMPOSE_FRONTEND_IMAGE=.*#COMPOSE_FRONTEND_IMAGE=${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA}#' .env + docker pull ${CI_REGISTRY_IMAGE}/web:${CI_COMMIT_SHA} + docker pull ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA} + docker-compose rm --force backend + docker-compose rm --force frontend + docker-compose up -d backend + docker-compose up -d frontend + EOF diff --git a/README.md b/README.md new file mode 100755 index 0000000..b9ce876 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +## EVG Outbo + +### Technology stack +1. React 16.9 + Typescript 3.6 +1. Django 2.2 \ No newline at end of file diff --git a/api.Dockerfile b/api.Dockerfile new file mode 100755 index 0000000..e15b5f2 --- /dev/null +++ b/api.Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.8 + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN apt-get update && apt-get install -y nano && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /code && chown -R www-data:www-data /code +RUN mkdir -p /var/www && chown -R www-data:www-data /var/www + +USER www-data +ENV PATH /var/www/.local/bin:${PATH} + +WORKDIR /code +COPY pyproject.toml poetry.lock /code/ + +RUN pip install --upgrade -U pip +RUN pip install -U poetry + +RUN poetry config virtualenvs.create false --local +RUN poetry install --no-dev --extras "production" + +COPY . /code/ +CMD exec gunicorn outbo.wsgi:application --access-logfile - --bind 0.0.0.0:8000 --workers 2 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100755 index 0000000..4df9006 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,21 @@ +version: "3.8" +services: + db: + image: postgres:12.3 + restart: always + environment: + - POSTGRES_USER=post + - POSTGRES_PASSWORD=post1 + - POSTGRES_DB=post + volumes: + - ../.local/outbo/db:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + networks: + - internal-network + - external-network + +networks: + internal-network: + internal: true + external-network: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100755 index 0000000..56bf5f0 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,65 @@ +version: "3.2" +services: + db: + env_file: + - .env + image: postgres:11 + restart: unless-stopped + volumes: + - ./db:/var/lib/postgresql/data + networks: + - internal-network + + pgadmin: + env_file: + - .env + image: dpage/pgadmin4 + volumes: + - ./pgadmin:/var/lib/pgadmin + restart: unless-stopped + depends_on: + - db + networks: + - internal-network + + backend: + env_file: + - .env + image: "${COMPOSE_BACKEND_IMAGE}" + restart: unless-stopped + depends_on: + - db + networks: + - internal-network + - external-network + + frontend: + env_file: + - .env + image: "${COMPOSE_FRONTEND_IMAGE}" + restart: unless-stopped + depends_on: + - backend + networks: + - internal-network + + server: + env_file: + - .env + image: "abiosoft/caddy" + restart: unless-stopped + volumes: + - ./Caddyfile:/etc/Caddyfile + - ./.caddy:/root/.caddy + ports: + - 80:80 + - 443:443 + networks: + - internal-network + - external-network + + +networks: + internal-network: + internal: true + external-network: \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100755 index 0000000..34d2f50 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,60 @@ +version: "3.2" +services: + db: + image: postgres:11 + restart: "no" + env_file: + - staging.env + volumes: + - ./db:/var/lib/postgresql/data + networks: + - internal-network + + pgadmin: + env_file: + - staging.env + image: dpage/pgadmin4 + volumes: + - ./pgadmin:/var/lib/pgadmin + ports: + - "${COMPOSE_PGADMIN_PORT}:80" + restart: "no" + depends_on: + - db + networks: + - internal-network + - external-network + + backend: + image: "__BACKEND_IMAGE__" + restart: "no" + command: bash -c "python manage.py collectstatic --no-input && python manage.py migrate --no-input && exec gunicorn outbo.wsgi:application --access-logfile - --bind 0.0.0.0:8000 --workers 2" + env_file: + - staging.env + volumes: + - '/var/www/html/evg-outbo:/host' + ports: + - "${COMPOSE_BACKEND_PORT}:8000" + depends_on: + - db + networks: + - internal-network + - external-network + + frontend: + image: "__FRONTEND_IMAGE__" + restart: "no" + env_file: + - staging.env + ports: + - "${COMPOSE_FRONTEND_PORT}:80" + depends_on: + - backend + networks: + - internal-network + - external-network + +networks: + internal-network: + internal: true + external-network: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..176df0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.2" +services: + backend: + build: + context: ./evg-outbo-api + dockerfile: api.Dockerfile + frontend: + build: + context: ./evg-outbo-web + dockerfile: web.Dockerfile diff --git a/evg-outbo-api/.DS_Store b/evg-outbo-api/.DS_Store new file mode 100644 index 0000000..e6dee25 Binary files /dev/null and b/evg-outbo-api/.DS_Store differ diff --git a/evg-outbo-api/.coveragerc b/evg-outbo-api/.coveragerc new file mode 100755 index 0000000..cca4845 --- /dev/null +++ b/evg-outbo-api/.coveragerc @@ -0,0 +1,14 @@ +[run] +branch = True +source = outbo +omit = + */migrations/* + +[report] +exclude_lines = + pragma: no cover + if __name__ == .__main__.: + if TYPE_CHECKING: + +[html] +directory = htmlcov/ \ No newline at end of file diff --git a/evg-outbo-api/.flake8 b/evg-outbo-api/.flake8 new file mode 100755 index 0000000..4e46ea9 --- /dev/null +++ b/evg-outbo-api/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 80 +max-complexity = 18 +select = C,E,F,W,B,B950 +ignore = E203, E501, W503 diff --git a/evg-outbo-api/.gitignore b/evg-outbo-api/.gitignore new file mode 100755 index 0000000..96c7526 --- /dev/null +++ b/evg-outbo-api/.gitignore @@ -0,0 +1,286 @@ + +# Created by https://www.gitignore.io/api/python,django,pycharm+all +# Edit at https://www.gitignore.io/?templates=python,django,pycharm+all + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo + +# Django stuff: +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +dev.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# celery beat schedule file + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# End of https://www.gitignore.io/api/python,django,pycharm+all + + +# static & upload files +/static/ +/uploads/ diff --git a/evg-outbo-api/manage.py b/evg-outbo-api/manage.py new file mode 100755 index 0000000..9894401 --- /dev/null +++ b/evg-outbo-api/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +from patches import patches + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "outbo.settings") + os.environ.setdefault("DJANGO_CONFIGURATION", "Dev") + + from configurations.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/evg-outbo-api/outbo/__init__.py b/evg-outbo-api/outbo/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/__init__.py b/evg-outbo-api/outbo/apps/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/activity_log/__init__.py b/evg-outbo-api/outbo/apps/activity_log/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/activity_log/admin.py b/evg-outbo-api/outbo/apps/activity_log/admin.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/activity_log/enums.py b/evg-outbo-api/outbo/apps/activity_log/enums.py new file mode 100755 index 0000000..041bb83 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/enums.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class MessageType(Enum): + USER = "User" + SUCCESS = "Success" + FAILURE = "Failure" diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/activity_log/migrations/0001_initial.py new file mode 100755 index 0000000..53c79f5 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/migrations/0001_initial.py @@ -0,0 +1,107 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.activity_log.enums +import outbo.apps.core.fields +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="LogItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("message", models.TextField(verbose_name="message")), + ( + "message_type", + models.CharField( + choices=[ + (outbo.apps.activity_log.enums.MessageType("User"), "User"), + ( + outbo.apps.activity_log.enums.MessageType("Success"), + "Success", + ), + ( + outbo.apps.activity_log.enums.MessageType("Failure"), + "Failure", + ), + ], + default=outbo.apps.activity_log.enums.MessageType("User"), + max_length=25, + verbose_name="message type", + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="ProductLogItem", + fields=[ + ( + "logitem_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="activity_log.LogItem", + ), + ), + ], + options={"ordering": ["-created_at"],}, + bases=("activity_log.logitem",), + ), + migrations.CreateModel( + name="ProjectLogItem", + fields=[ + ( + "logitem_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="activity_log.LogItem", + ), + ), + ], + options={"ordering": ["-created_at"],}, + bases=("activity_log.logitem",), + ), + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/0002_auto_20191129_0957.py b/evg-outbo-api/outbo/apps/activity_log/migrations/0002_auto_20191129_0957.py new file mode 100755 index 0000000..8460388 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/migrations/0002_auto_20191129_0957.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("activity_log", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="logitem", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + migrations.AddField( + model_name="logitem", + name="polymorphic_ctype", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_activity_log.logitem_set+", + to="contenttypes.ContentType", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/0003_projectlogitem_project.py b/evg-outbo-api/outbo/apps/activity_log/migrations/0003_projectlogitem_project.py new file mode 100755 index 0000000..9708030 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/migrations/0003_projectlogitem_project.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0001_initial"), + ("activity_log", "0002_auto_20191129_0957"), + ] + + operations = [ + migrations.AddField( + model_name="projectlogitem", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="log_items", + related_query_name="log_item", + to="projects.Project", + verbose_name="project", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/0004_auto_20191129_0957.py b/evg-outbo-api/outbo/apps/activity_log/migrations/0004_auto_20191129_0957.py new file mode 100755 index 0000000..554237f --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/migrations/0004_auto_20191129_0957.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("activity_log", "0003_projectlogitem_project"), + ("products", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="productlogitem", + name="product", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="log_items", + related_query_name="log_item", + to="products.Product", + verbose_name="product", + ), + ), + migrations.AddField( + model_name="productlogitem", + name="stage", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="products.ProductStage", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/0005_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/activity_log/migrations/0005_auto_20200525_1025.py new file mode 100755 index 0000000..8d9bfb4 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/migrations/0005_auto_20200525_1025.py @@ -0,0 +1,56 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("activity_log", "0004_auto_20191129_0957"), + ] + + operations = [ + migrations.AlterField( + model_name="logitem", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + migrations.AlterField( + model_name="productlogitem", + name="product", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="log_items", + related_query_name="log_item", + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="productlogitem", + name="stage", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="products.ProductStage", + ), + ), + migrations.AlterField( + model_name="projectlogitem", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="log_items", + related_query_name="log_item", + to="projects.Project", + verbose_name="project", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/migrations/__init__.py b/evg-outbo-api/outbo/apps/activity_log/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/activity_log/models.py b/evg-outbo-api/outbo/apps/activity_log/models.py new file mode 100755 index 0000000..9e5bddd --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.utils.translation import pgettext, gettext_lazy as _ +from rules import always_allow + +from outbo.apps.activity_log.enums import MessageType +from outbo.apps.core.models import ( + TimestampableDeletableMixin, + RulesPolymorphicModel, + SoftDeletablePolymorphicManager, +) + + +class LogItem(TimestampableDeletableMixin, RulesPolymorphicModel): + objects = SoftDeletablePolymorphicManager() + all_objects = SoftDeletablePolymorphicManager(alive_only=False) + + message = models.TextField(verbose_name=_("message")) + message_type = models.CharField( + verbose_name=_("message type"), + max_length=25, + blank=False, + null=False, + default=MessageType.USER, + choices=((type, type.value) for type in MessageType), + ) + created_by = models.ForeignKey( + verbose_name=_("created by"), to="authentication.User", on_delete=models.CASCADE + ) + + def __str__(self): + if self.message_type == MessageType.USER: + return self.message + return pgettext("log_item_messages", self.message) + + +class ProductLogItem(LogItem): + class Meta: + rules_permissions = {"view": always_allow, "add": always_allow} + ordering = ["-created_at"] + + product = models.ForeignKey( + verbose_name=_("product"), + to="products.Product", + on_delete=models.CASCADE, + related_name="log_items", + related_query_name="log_item", + ) + + stage = models.ForeignKey( + to="products.ProductStage", on_delete=models.CASCADE, null=True, default=None + ) + + +class ProjectLogItem(LogItem): + class Meta: + rules_permissions = {"view": always_allow, "add": always_allow} + ordering = ["-created_at"] + + project = models.ForeignKey( + verbose_name=_("project"), + to="projects.Project", + on_delete=models.CASCADE, + related_name="log_items", + related_query_name="log_item", + ) diff --git a/evg-outbo-api/outbo/apps/activity_log/serializers.py b/evg-outbo-api/outbo/apps/activity_log/serializers.py new file mode 100755 index 0000000..404646a --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/serializers.py @@ -0,0 +1,84 @@ +from rest_framework import serializers + +from outbo.apps.activity_log.enums import MessageType +from outbo.apps.activity_log.models import ProductLogItem, ProjectLogItem + + +class BaseProductLogItemSerializer(serializers.ModelSerializer): + class Meta: + model = ProductLogItem + + def get_message_type(self, instance): + t, val = str(instance.message_type).split(".", 1) + return MessageType[val].value + + +class ProductLogItemSerializer: + class List(BaseProductLogItemSerializer): + message_type = serializers.SerializerMethodField() + + class Meta(BaseProductLogItemSerializer.Meta): + fields = [ + "message", + "message_type", + "stage", + "updated_at", + "created_at", + "created_by", + ] + + class Create(BaseProductLogItemSerializer): + class Meta(BaseProductLogItemSerializer.Meta): + fields = ["message"] + + class AfterCreate(BaseProductLogItemSerializer): + message_type = serializers.SerializerMethodField() + + class Meta(BaseProductLogItemSerializer.Meta): + fields = [ + "message", + "message_type", + "stage", + "updated_at", + "created_at", + "created_by", + ] + + +class BaseProjectLogItemSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectLogItem + + def get_message_type(self, instance): + t, val = str(instance.message_type).split(".", 1) + return MessageType[val].value + + +class ProjectLogItemSerializer: + class List(BaseProjectLogItemSerializer): + message_type = serializers.SerializerMethodField() + + class Meta(BaseProjectLogItemSerializer.Meta): + fields = [ + "message", + "message_type", + "updated_at", + "created_at", + "created_by", + ] + + class Create(BaseProjectLogItemSerializer): + class Meta(BaseProjectLogItemSerializer.Meta): + fields = ["message"] + + class AfterCreate(BaseProjectLogItemSerializer): + message_type = serializers.SerializerMethodField() + + class Meta(BaseProjectLogItemSerializer.Meta): + fields = [ + "message", + "message_type", + "updated_at", + "created_at", + "created_by", + ] diff --git a/evg-outbo-api/outbo/apps/activity_log/urls.py b/evg-outbo-api/outbo/apps/activity_log/urls.py new file mode 100755 index 0000000..8b6301a --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/urls.py @@ -0,0 +1,2 @@ +app_name = "activity_log" +urlpatterns = [] diff --git a/evg-outbo-api/outbo/apps/activity_log/viewsets.py b/evg-outbo-api/outbo/apps/activity_log/viewsets.py new file mode 100755 index 0000000..cc1dd67 --- /dev/null +++ b/evg-outbo-api/outbo/apps/activity_log/viewsets.py @@ -0,0 +1,48 @@ +from outbo.apps.activity_log.models import ProductLogItem, ProjectLogItem +from outbo.apps.activity_log.serializers import ( + ProductLogItemSerializer, + ProjectLogItemSerializer, +) +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + ListModelMixin, + MultipleSerializerViewSet, + NestedViewSetMixin, + CreateModelMixin, +) +from outbo.apps.products.models import Product +from outbo.apps.projects.models import Project + + +class ProductLogItemNestedViewSet( + AutoPermissionViewSetMixin, + ListModelMixin, + CreateModelMixin, + NestedViewSetMixin, + MultipleSerializerViewSet, +): + queryset = ProductLogItem.objects.all() + serializer_class_set = ProductLogItemSerializer + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + product = Product.objects.get(pk=parents["product_id"]) + return serializer.save(product=product, created_by=current_user) + + +class ProjectLogItemNestedViewSet( + AutoPermissionViewSetMixin, + ListModelMixin, + CreateModelMixin, + NestedViewSetMixin, + MultipleSerializerViewSet, +): + queryset = ProjectLogItem.objects.all() + serializer_class_set = ProjectLogItemSerializer + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + project = Project.objects.get(pk=parents["project_id"]) + return serializer.save(project=project, created_by=current_user) diff --git a/evg-outbo-api/outbo/apps/authentication/__init__.py b/evg-outbo-api/outbo/apps/authentication/__init__.py new file mode 100755 index 0000000..a0ac841 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/__init__.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ +from rest_framework.authentication import TokenAuthentication as BaseTokenAuthentication + + +class TokenAuthentication(BaseTokenAuthentication): + keyword = "Bearer" + + +class AuthenticationAppConfig(AppConfig): + name = "outbo.apps.authentication" + label = "authentication" + verbose_name = _("Authentication") + + +default_app_config = "outbo.apps.authentication.AuthenticationAppConfig" diff --git a/evg-outbo-api/outbo/apps/authentication/admin.py b/evg-outbo-api/outbo/apps/authentication/admin.py new file mode 100755 index 0000000..85fbac0 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ + +from outbo.apps.authentication.models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + fieldsets = ( + (None, {"fields": ("username", "password")}), + ( + _("Personal info"), + {"fields": ("first_name", "last_name", "email", "photo", "phone_number")}, + ), + (_("Organization info"), {"fields": ("organization", "job_title", "is_observer", "is_trader", "category")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("username", "email", "password1", "password2", "is_observer", "is_trader", "category"), + }, + ), + ) + list_display = ("username", "email", "first_name", "last_name") + list_filter = () + search_fields = ("username", "first_name", "last_name", "email") + ordering = ("username",) + filter_horizontal = () + autocomplete_fields = ["organization"] + + def get_queryset(self, request): + return super().get_queryset(request).filter(is_superuser=False) diff --git a/evg-outbo-api/outbo/apps/authentication/managers.py b/evg-outbo-api/outbo/apps/authentication/managers.py new file mode 100755 index 0000000..8fd1af1 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/managers.py @@ -0,0 +1,41 @@ +from django.contrib.auth.base_user import BaseUserManager + +from outbo.apps.core.models import SoftDeletableManager + + +class UserManager(SoftDeletableManager, BaseUserManager): + def __init__(self, *, alive_only=False): + self.alive_only = alive_only + super().__init__() + + def create_user(self, username, email, password=None, save=True, **kwargs): + if username is None: + raise TypeError("User must have a username.") + if email is None: + raise TypeError("User must have an email address.") + + user = self.model( + username=username, email=self.normalize_email(email), **kwargs + ) + user.set_password(password) + if save: + user.save() + + return user + + def create_superuser(self, username, email, password, save=True): + if password is None: + raise TypeError("Superuser must have a password.") + + user = self.create_user( + username=username, email=email, password=password, save=False + ) + user.is_superuser = True + user.is_staff = True + if save: + user.save() + + return user + + def regular_users(self): + return self.filter(is_staff=False) diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/authentication/migrations/0001_initial.py new file mode 100755 index 0000000..ea4c8ad --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +import django.contrib.auth.validators +from django.db import migrations, models +import outbo.apps.core.fields +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "email", + models.EmailField( + blank=True, + max_length=254, + unique=True, + verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "photo", + models.ImageField( + blank=True, + null=True, + upload_to="users/", + verbose_name="profile photo", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ], + options={"verbose_name": "user", "verbose_name_plural": "users",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0002_auto_20191129_0957.py b/evg-outbo-api/outbo/apps/authentication/migrations/0002_auto_20191129_0957.py new file mode 100755 index 0000000..5ba5036 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0002_auto_20191129_0957.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentication", "0001_initial"), + ("organizations", "0001_initial"), + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="organization", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="organizations.Organization", + verbose_name="organization", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0003_auto_20200116_1318.py b/evg-outbo-api/outbo/apps/authentication/migrations/0003_auto_20200116_1318.py new file mode 100755 index 0000000..d9c9a5d --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0003_auto_20200116_1318.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.8 on 2020-01-16 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0002_auto_20191129_0957"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="job_title", + field=models.CharField(blank=True, max_length=30, verbose_name="job title"), + ), + migrations.AddField( + model_name="user", + name="phone_number", + field=models.CharField( + blank=True, max_length=30, verbose_name="phone_number" + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0004_auto_20200211_1405.py b/evg-outbo-api/outbo/apps/authentication/migrations/0004_auto_20200211_1405.py new file mode 100755 index 0000000..265be56 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0004_auto_20200211_1405.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.8 on 2020-02-11 13:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0003_auto_20200116_1318"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField( + blank=True, + max_length=254, + null=True, + unique=True, + verbose_name="email address", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0005_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/authentication/migrations/0005_auto_20200525_1025.py new file mode 100755 index 0000000..a080cef --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0005_auto_20200525_1025.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0004_auto_20200211_1405"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="organization", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="organizations.Organization", + verbose_name="organization", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0006_user_is_observer.py b/evg-outbo-api/outbo/apps/authentication/migrations/0006_user_is_observer.py new file mode 100755 index 0000000..ad582eb --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0006_user_is_observer.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2020-11-03 13:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0005_auto_20200525_1025'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_observer', + field=models.BooleanField(default=False, help_text='Designates whether the user is an observer.', verbose_name='observer status'), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0007_user_is_trader.py b/evg-outbo-api/outbo/apps/authentication/migrations/0007_user_is_trader.py new file mode 100755 index 0000000..41b38e5 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0007_user_is_trader.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.22 on 2021-05-11 07:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0006_user_is_observer'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_trader', + field=models.BooleanField(default=False, help_text='Designates whether the user is an trader.', verbose_name='trader status'), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0008_user_category.py b/evg-outbo-api/outbo/apps/authentication/migrations/0008_user_category.py new file mode 100755 index 0000000..1797b0c --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0008_user_category.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.22 on 2021-05-11 09:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0002_auto_20200525_1025'), + ('authentication', '0007_user_is_trader'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.Category', verbose_name='category'), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/0009_auto_20210517_1232.py b/evg-outbo-api/outbo/apps/authentication/migrations/0009_auto_20210517_1232.py new file mode 100755 index 0000000..0040d02 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/migrations/0009_auto_20210517_1232.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.22 on 2021-05-17 10:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0008_user_category'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.Category', verbose_name='category for trader'), + ), + ] diff --git a/evg-outbo-api/outbo/apps/authentication/migrations/__init__.py b/evg-outbo-api/outbo/apps/authentication/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/authentication/models.py b/evg-outbo-api/outbo/apps/authentication/models.py new file mode 100755 index 0000000..d53336d --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/models.py @@ -0,0 +1,160 @@ +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.contrib.contenttypes.models import ContentType +from django.core.mail import send_mail +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModelMixin, RulesModelBase + +from outbo.apps.authentication import rules +from outbo.apps.authentication.managers import UserManager +from outbo.apps.core.models import TimestampableDeletableMixin + + +class User( + RulesModelMixin, + TimestampableDeletableMixin, + AbstractBaseUser, + PermissionsMixin, + metaclass=RulesModelBase, +): + objects = UserManager() + all_objects = UserManager(alive_only=False) + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + rules_permissions = { + "read": rules.can_read_user, + "read_current": rules.can_read_current_user, + } + + username_validator = UnicodeUsernameValidator() + + username = models.CharField( + verbose_name=_("username"), + max_length=150, + unique=True, + help_text=_( + "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." + ), + validators=[username_validator], + error_messages={"unique": _("A user with that username already exists.")}, + ) + + email = models.EmailField( + verbose_name=_("email address"), blank=True, null=True, unique=True + ) + + is_staff = models.BooleanField( + verbose_name=_("staff status"), + default=False, + help_text=_("Designates whether the user can log into this admin site."), + ) + + is_observer = models.BooleanField( + verbose_name=_("observer status"), + default=False, + help_text=_("Designates whether the user is an observer."), + ) + + is_trader = models.BooleanField( + verbose_name=_("trader status"), + default=False, + help_text=_("Designates whether the user is an trader."), + ) + + first_name = models.CharField( + verbose_name=_("first name"), max_length=30, blank=True + ) + last_name = models.CharField( + verbose_name=_("last name"), max_length=150, blank=True + ) + organization = models.ForeignKey( + verbose_name=_("organization"), + to="organizations.Organization", + on_delete=models.CASCADE, + null=True, + default=None, + ) + + category = models.ForeignKey( + verbose_name=_("category for trader"), + to="organizations.Category", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + + photo = models.ImageField( + verbose_name=_("profile photo"), upload_to="users/", blank=True, null=True + ) + + job_title = models.CharField(verbose_name=_("job title"), max_length=30, blank=True) + phone_number = models.CharField( + verbose_name=_("phone_number"), max_length=30, blank=True + ) + + USERNAME_FIELD = "username" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = [] + + @property + def photo_url(self): + return None if not self.photo else self.photo.url + + @property + def is_active(self): + return self.deleted_at is None + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}".strip() + + @property + def is_agency(self): + return ( + False + if not self.organization + else self.organization.type + == ContentType.objects.get( + app_label="organizations", model="agencyorganization" + ) + ) + + @property + def is_client(self): + return ( + False + if not self.organization + else self.organization.type + == ContentType.objects.get( + app_label="organizations", model="clientorganization" + ) + ) + + @property + def is_supplier(self): + return ( + False + if not self.organization + else self.organization.type + == ContentType.objects.get( + app_label="organizations", model="supplierorganization" + ) + ) + + @property + def user_role(self): + from outbo.apps.projects.models.member import MembershipType + + if self.is_client: + return MembershipType.CLIENT + elif self.is_agency: + return MembershipType.AGENCY + elif self.is_supplier: + return MembershipType.SUPPLIER + + def email_user(self, subject, message, from_email=None, **kwargs): + """Send an email to this user.""" + send_mail(subject, message, from_email, [self.email], **kwargs) diff --git a/evg-outbo-api/outbo/apps/authentication/rules.py b/evg-outbo-api/outbo/apps/authentication/rules.py new file mode 100755 index 0000000..c4b9ce5 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/rules.py @@ -0,0 +1,10 @@ +from rules import predicate + + +@predicate +def _is_active(user): + return user.is_active + + +can_read_user = _is_active +can_read_current_user = _is_active diff --git a/evg-outbo-api/outbo/apps/authentication/serializers.py b/evg-outbo-api/outbo/apps/authentication/serializers.py new file mode 100755 index 0000000..12fd25f --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/serializers.py @@ -0,0 +1,74 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.authtoken.serializers import ( + AuthTokenSerializer as BaseAuthTokenSerializer, +) +from outbo.apps.authentication.models import User +from outbo.apps.organizations.models import Organization +from outbo.apps.organizations.serializers import OrganizationSerializer + + +class AuthTokenSerializer(BaseAuthTokenSerializer): + def validate(self, attrs): + validated_attrs = super().validate(attrs) + user = validated_attrs["user"] + if user.is_staff: + msg = _("Administrators cannot login via API.") + raise serializers.ValidationError(msg, code="authorization") + return validated_attrs + + +class _BaseUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + + +class UserSerializer: + class List(_BaseUserSerializer): + organization_name = serializers.SlugRelatedField( + source="organization", slug_field="name", read_only=True + ) + photo = serializers.SerializerMethodField() + + class Meta(_BaseUserSerializer.Meta): + fields = [ + "id", + "full_name", + "organization_name", + "photo", + "email", + "phone_number", + "job_title", + ] + + def get_photo(self, instance: User): + if instance.photo_url: + return self.context["request"].build_absolute_uri(instance.photo_url) + + class Retrieve(_BaseUserSerializer): + organization = serializers.SerializerMethodField() + photo = serializers.SerializerMethodField() + + class Meta(_BaseUserSerializer.Meta): + fields = [ + "id", + "username", + "full_name", + "organization", + "photo", + "email", + "phone_number", + "job_title", + "is_observer", + "is_trader", + ] + + def get_photo(self, instance: User): + if instance.photo_url: + return self.context["request"].build_absolute_uri(instance.photo_url) + + # NOTE: Hacky workaround + def get_organization(self, instance): + serializer = OrganizationSerializer.List(read_only=True) + serializer.instance = Organization.objects.get(id=instance.organization_id) + return serializer.data diff --git a/evg-outbo-api/outbo/apps/authentication/urls.py b/evg-outbo-api/outbo/apps/authentication/urls.py new file mode 100755 index 0000000..864df25 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from rest_framework import routers + +from outbo.apps.authentication.views import ObtainAuthToken +from outbo.apps.authentication.viewsets import UserViewSet + +app_name = "authentication" + +router = routers.SimpleRouter() +router.register(prefix="users", viewset=UserViewSet) + +view_urls = [path("token/", ObtainAuthToken.as_view(), name="token")] +urlpatterns = view_urls + router.urls diff --git a/evg-outbo-api/outbo/apps/authentication/views.py b/evg-outbo-api/outbo/apps/authentication/views.py new file mode 100755 index 0000000..04bcb15 --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/views.py @@ -0,0 +1,19 @@ +from djangorestframework_camel_case.parser import ( + CamelCaseFormParser, + CamelCaseMultiPartParser, + CamelCaseJSONParser, +) +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from rest_framework.authtoken.views import ObtainAuthToken as BaseObtainAuthToken + +from outbo.apps.authentication.serializers import AuthTokenSerializer + + +class ObtainAuthToken(BaseObtainAuthToken): + serializer_class = AuthTokenSerializer + renderer_classes = [CamelCaseJSONRenderer] + parser_classes = [ + CamelCaseFormParser, + CamelCaseMultiPartParser, + CamelCaseJSONParser, + ] diff --git a/evg-outbo-api/outbo/apps/authentication/viewsets.py b/evg-outbo-api/outbo/apps/authentication/viewsets.py new file mode 100755 index 0000000..e5d5a5f --- /dev/null +++ b/evg-outbo-api/outbo/apps/authentication/viewsets.py @@ -0,0 +1,32 @@ +from rest_framework.decorators import action +from rest_framework.response import Response + +from outbo.apps.authentication.models import User +from outbo.apps.authentication.serializers import UserSerializer +from outbo.apps.core.viewsets import ( + MultipleSerializerViewSet, + AutoPermissionViewSetMixin, + ListModelMixin, + RetrieveModelMixin, +) + + +class UserViewSet( + RetrieveModelMixin, + ListModelMixin, + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, +): + queryset = User.objects.regular_users() + serializer_class_set = UserSerializer + serializer_map = {**MultipleSerializerViewSet.serializer_map, "me": "Retrieve"} + permission_type_map = { + **AutoPermissionViewSetMixin.permission_type_map, + "me": "read_current", + } + + @action(detail=False, methods=["get"]) + def me(self, request): + current_user = request.user + serializer = self.get_serializer(current_user) + return Response(serializer.data) diff --git a/evg-outbo-api/outbo/apps/core/__init__.py b/evg-outbo-api/outbo/apps/core/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/core/admin.py b/evg-outbo-api/outbo/apps/core/admin.py new file mode 100755 index 0000000..5f0948c --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/admin.py @@ -0,0 +1,18 @@ +from nested_admin.nested import NestedTabularInline +from polymorphic.admin import ( + PolymorphicParentModelAdmin as BasePolymorphicParentModelAdmin, +) + + +class PolymorphicParentModelAdmin(BasePolymorphicParentModelAdmin): + def get_child_type_choices(self, request, action): + choices = super().get_child_type_choices(request=request, action=action) + return tuple((ct_id, label.title()) for (ct_id, label) in choices) + + +class NestedTabularSingleItemInline(NestedTabularInline): + template = "nesting/admin/inlines/tabular_single.html" + + +class NestedStackedSingleItemInline(NestedTabularInline): + template = "nesting/admin/inlines/stacked_single.html" diff --git a/evg-outbo-api/outbo/apps/core/fields.py b/evg-outbo-api/outbo/apps/core/fields.py new file mode 100755 index 0000000..916ec23 --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/fields.py @@ -0,0 +1,122 @@ +from functools import partial + +from django import forms +from django.db import models +from django_extensions.db.fields import ( + CreationDateTimeField as BaseCreationDateTimeField, + ModificationDateTimeField as BaseModificationDateTimeField, +) +from django_fsm import FSMFieldMixin + +from outbo.apps.core.forms import EnumChoiceField + + +class CreationDateTimeField(BaseCreationDateTimeField): + """ + CreationDateTimeField + + By default, sets editable=False, blank=True, auto_now_add=True + """ + + +class ModificationDateTimeField(BaseModificationDateTimeField): + """ + ModificationDateTimeField + + By default, sets editable=False, blank=True, auto_now=True + + Sets value to now every time the object is saved. + """ + + +class RemovalDateTimeField(models.DateTimeField): + """ + RemovalDateTimeField + + By default, sets editable=False, blank=True, null=True + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("editable", False) + kwargs.setdefault("blank", True) + kwargs.setdefault("null", True) + models.DateTimeField.__init__(self, *args, **kwargs) + + def get_internal_type(self): + return "DateTimeField" + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + if self.editable is not False: + kwargs["editable"] = True + if self.blank is not True: + kwargs["blank"] = False + if self.null is not True: + kwargs["null"] = False + return name, path, args, kwargs + + +class FSMKeyField(FSMFieldMixin, models.ForeignKey): + def get_state(self, instance): + return instance.__dict__[self.attname] + + def set_state(self, instance, state): + instance.__dict__[self.attname] = self.to_python(state) + + +class EnumCharField(models.CharField): + def __init__(self, *args, **kwargs): + enum = kwargs.pop("enum", None) + if enum is not None: + if kwargs.get("choices", None) is not None: + raise ValueError("Cannot provide choices for EnumCharField") + kwargs["choices"] = [(item, item.value) for item in enum] + super().__init__(*args, **kwargs) + self.validators.pop() # lengthvalidator not necessary on this one + self.enum = enum + + def get_internal_type(self): + return "CharField" + + def formfield(self, **kwargs): + enum_form_class = partial(EnumChoiceField, enum=self.enum) + defaults = { + "widget": forms.Select, + "form_class": enum_form_class, + "choices": [(str(item), item.value) for item in self.enum], + } + defaults.update(kwargs) + return super().formfield(**defaults) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs["enum"] = self.enum + if "choices" in kwargs: + del kwargs["choices"] + + return name, path, args, kwargs + + def to_python(self, value): + value = super().to_python(value) + if value is not None: + if not isinstance(value, self.enum): + enum_name, enum_value = value.split(".", 1) + if enum_name == self.enum.__name__: + return self.enum[enum_value] + return value + + def get_prep_value(self, value): + value = super().get_prep_value(value) + if value is None: + return value + + if isinstance(value, self.enum): + return str(value) + return value + + def from_db_value(self, value, *args): + if value is not None: + enum_name, enum_value = value.split(".", 1) + if enum_name == self.enum.__name__: + return self.enum[enum_value] + return value diff --git a/evg-outbo-api/outbo/apps/core/forms.py b/evg-outbo-api/outbo/apps/core/forms.py new file mode 100755 index 0000000..8332f15 --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/forms.py @@ -0,0 +1,29 @@ +from django import forms +from django.core.exceptions import ValidationError + + +class EnumChoiceField(forms.ChoiceField): + def __init__(self, enum, **kwargs): + kwargs.setdefault("choices", [(item, item.value) for item in enum]) + super().__init__(**kwargs) + self.enum = enum + + def prepare_value(self, value): + if isinstance(value, self.enum): + return str(value) + return value + + def to_python(self, value): + value = super().to_python(value) + if not isinstance(value, self.enum): + try: + enum_name, enum_value = value.split(".", 1) + if enum_name == self.enum.__name__: + value = self.enum[enum_value] + except ValueError: + raise ValidationError(self.error_messages["invalid"], code="invalid") + return value + + def clean(self, value): + value = super().clean(value) + return self.enum(value) diff --git a/evg-outbo-api/outbo/apps/core/management/__init__.py b/evg-outbo-api/outbo/apps/core/management/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/core/management/commands/__init__.py b/evg-outbo-api/outbo/apps/core/management/commands/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/core/management/commands/seed.py b/evg-outbo-api/outbo/apps/core/management/commands/seed.py new file mode 100755 index 0000000..e6eab9e --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/management/commands/seed.py @@ -0,0 +1,236 @@ +from typing import TYPE_CHECKING, Tuple, Iterable + +from django.core.management.base import BaseCommand +from faker import Factory + +from tests.factories.authentication import ( + UserFactory, + AgencyUserFactory, + ClientUserFactory, + SupplierUserFactory, +) +from tests.factories.organizations import ( + CategoryFactory, + BrandFactory, +) + +from tests.factories.products import ProductStageFactory, PackagingTypeFactory +from tests.factories.token import TokenFactory + +faker = Factory() + +if TYPE_CHECKING: + from outbo.apps.organizations.models import ClientOrganization + + +class Command(BaseCommand): + help = "Seed with some existing data" + + def add_arguments(self, parser): + parser.add_argument("--dev", action="store_true", help="Seed with dev data") + + def __make_users_and_organizations(self, *, password=None): + self.stdout.write("Seeding organizations and users...") + + if password is None: + password = "password" + + UserFactory( + username="admin", + password=password, + email="admin@example.com", + is_superuser=True, + is_staff=True, + ) + + agency = AgencyUserFactory( + username="agency", + password=password, + email="agency@example.com", + organization__name="Sample agency", + ) + + client = ClientUserFactory( + username="client", + password=password, + email="client@example.com", + organization__name="Sample client", + ) + + supplier = SupplierUserFactory( + username="supplier", + password=password, + email="supplier@example.com", + organization__name="Sample supplier", + ) + + TokenFactory(user=agency, key="6f9afaa36ee8b224181dc14e2ca9af33e97c0f71") + TokenFactory(user=client, key="481372f60325289029679df25c72e6ccd05892ed") + TokenFactory(user=supplier, key="d23d8f187614c8a76fe7316470e6f50ff5bc4188") + + def __make_product_stages(self): + self.stdout.write("Seeding product stages...") + + ProductStageFactory( + id="waiting_for_brief", + label="1", + group=1, + description="Briefing", + next_action="Oczekuje na uzupełnienie briefu", + order=1, + ) + ProductStageFactory( + id="waiting_for_brief_accept_by_agency", + label="1A", + group=1, + description="Akceptacja agencji", + next_action="Oczekuje na akceptację agencji", + order=2, + ) + ProductStageFactory( + id="waiting_for_brief_accept_by_client", + label="1B", + group=1, + description="Akceptacja klienta", + next_action="Oczekuje na akceptację klienta", + order=3, + ) + ProductStageFactory( + id="waiting_for_draft", + label="2A", + group=2, + description="Publikacja draftu", + next_action="Oczekuje na publikację draftu", + order=4, + ) + ProductStageFactory( + id="waiting_for_draft_accept_by_supplier", + label="2B", + group=2, + description="Akceptacja dostawcy", + next_action="Oczekuje na akceptację dostawcy", + order=5, + ) + ProductStageFactory( + id="waiting_for_draft_accept_by_client", + label="2C", + group=2, + description="Akceptacja klienta", + next_action="Oczekuje na akceptację klientget_random_stringa", + order=6, + ) + ProductStageFactory( + id="waiting_for_ff", + label="3A", + group=3, + description="Publikacja FF", + next_action="Oczekuje na publikację draftu", + order=7, + ) + ProductStageFactory( + id="waiting_for_ff_accept_by_supplier", + label="3B", + group=3, + description="Akceptacja dostawcy", + next_action="Oczekuje na akceptację dostawcy", + order=8, + ) + ProductStageFactory( + id="waiting_for_ff_accept_by_client", + label="3C", + group=3, + description="Akceptacja klienta", + next_action="Oczekuje na akceptację klienta", + order=9, + ) + ProductStageFactory( + id="waiting_for_pf", + label="4A", + group=4, + description="Publikacja printing files", + next_action="Oczekuje na publikację printing files", + order=10, + ) + ProductStageFactory( + id="waiting_for_pf_accept_by_supplier", + label="4B", + group=4, + description="Akceptacja dostawcy", + next_action="Oczekuje na akceptację dostawcy", + order=11, + ) + ProductStageFactory( + id="waiting_for_pf_accept_by_client", + label="4C", + group=4, + description="Akceptacja klienta", + next_action="Oczekuje na akceptację klienta", + order=12, + ) + ProductStageFactory( + id="completed", + label="5", + group=5, + description="Zako\u0144czono", + next_action="Oczekuje na archiwizację", + order=13, + ) + + def __make_packaging_types(self, *, items: Iterable[str] = None): + self.stdout.write("Seeding packaging types...") + if items: + for name in items: + PackagingTypeFactory(name=name) + else: + PackagingTypeFactory.create_batch(size=20) + + def __make_brands( + self, *, items: Iterable[Tuple[str, "ClientOrganization"]] = None + ): + self.stdout.write("Seeding brands...") + if items: + for name, client in items: + BrandFactory(name=name, client=client) + else: + BrandFactory.create_batch(size=20) + + def __make_categories(self, *, items: Iterable[str] = None): + self.stdout.write("Seeding categories...") + if items: + for item in items: + CategoryFactory(name=item) + else: + CategoryFactory.create_batch(size=20) + + def handle(self, *args, **options): + self.stdout.write("Seeding the database...") + + if not options["dev"]: + packaging_types = ( + "Carton/gift box", + "Header", + "Hangtag", + "Paper band", + "Paper board", + "Blister", + "Image insert", + "Simple band", + "Label/sticker", + "Simple CDU/Displaybox", + ) + raise NotImplementedError() + + password = "!secret123" + + self.__make_users_and_organizations(password=password) + self.__make_product_stages() + self.__make_packaging_types() + self.__make_brands() + self.__make_categories() + + self.stdout.write("Seeding completed.") + self.stdout.write("Credentials:") + self.stdout.write(f" admin: {password}") + self.stdout.write(f" agency: {password}") + self.stdout.write(f" client: {password}") + self.stdout.write(f" supplier: {password}") diff --git a/evg-outbo-api/outbo/apps/core/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/core/migrations/0001_initial.py new file mode 100755 index 0000000..b8a1c6e --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 2.2.16 on 2021-01-21 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="EmailLog", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("sender", models.CharField(max_length=254)), + ("receiver", models.CharField(max_length=254)), + ("time", models.DateTimeField()), + ( + "status", + models.CharField( + choices=[ + ("sending", "sending"), + ("sent", "sent"), + ("queued", "queued"), + ("invalid", "invalid"), + ("rejected", "rejected"), + ("failed", "failed"), + ("unknown", "unknown"), + ], + max_length=20, + ), + ), + ], + ), + ] diff --git a/evg-outbo-api/outbo/apps/core/migrations/__init__.py b/evg-outbo-api/outbo/apps/core/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/core/models.py b/evg-outbo-api/outbo/apps/core/models.py new file mode 100755 index 0000000..6915b80 --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/models.py @@ -0,0 +1,117 @@ +from datetime import datetime + +from django.conf import settings +from django.db import models +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ +from model_utils import Choices +from polymorphic.base import PolymorphicModelBase +from polymorphic.managers import PolymorphicManager +from polymorphic.models import PolymorphicModel +from polymorphic.query import PolymorphicQuerySet +from rules.contrib.models import RulesModelBaseMixin, RulesModelMixin + +from outbo.apps.core.fields import ( + RemovalDateTimeField, + CreationDateTimeField, + ModificationDateTimeField, +) +from outbo.apps.core.utils import get_now + +EmailLogStatus = Choices("sending", "sent", "queued", "invalid", "rejected", "failed", "unknown") + + +class EmailLog(models.Model): + sender = models.CharField(max_length=254, blank=False, null=False) + receiver = models.CharField(max_length=254, blank=False, null=False) + time = models.DateTimeField(blank=False, null=False) + status = models.CharField(max_length=20, blank=False, null=False, choices=EmailLogStatus) + + +if "anymail" in settings.INSTALLED_APPS: + from anymail.signals import pre_send, post_send + from anymail.message import AnymailStatus + from django.core.mail import EmailMessage + + def log_email_message(sender: str, receiver: str, time: datetime, status: str): + try: + EmailLog.objects.create(sender=sender, receiver=receiver, time=time, status=status) + except: + pass + + @receiver(pre_send) + def handle_pre_send(sender, message: EmailMessage, **kwargs): + log_email_message( + sender=str(message.from_email), + receiver=", ".join(message.to), + time=get_now(), + status=EmailLogStatus.sending, + ) + + @receiver(post_send) + def handle_post_send(sender, message: EmailMessage, status: AnymailStatus, esp_name, **kwargs): + for email, recipient_status in status.recipients.items(): + log_email_message( + sender=str(message.from_email), + receiver=email, + time=get_now(), + status=recipient_status.status or "failed", + ) + + +class SoftDeletableQuerySet(models.QuerySet): + def delete(self): + return super().update(deleted_at=get_now()) + + def hard_delete(self): + return super().delete() + + +class SoftDeletableManager(models.Manager): + queryset_class = SoftDeletableQuerySet + + def __init__(self, *args, **kwargs): + self.alive_only = kwargs.pop("alive_only", True) + super().__init__(*args, **kwargs) + + def get_queryset(self): + if self.alive_only: + return self.queryset_class(self.model).filter(deleted_at=None) + return self.queryset_class(self.model) + + +class TimestampableDeletableMixin(models.Model): + objects = SoftDeletableManager() + all_objects = SoftDeletableManager(alive_only=False) + + created_at = CreationDateTimeField(_("Created at")) + updated_at = ModificationDateTimeField(_("Updated at")) + deleted_at = RemovalDateTimeField(_("Deleted at")) + + class Meta: + abstract = True + + def delete(self, using=None, **kwargs): + self.deleted_at = get_now() + # TODO: handle autodeletion of nested stuff + self.save(using=using) + + def __str__(self): + return super().__str__() if self.deleted_at is None else f"{super().__str__()} (deleted)" + + +class RulesPolymorphicModelBase(RulesModelBaseMixin, PolymorphicModelBase): + pass + + +class RulesPolymorphicModel(RulesModelMixin, PolymorphicModel, metaclass=RulesPolymorphicModelBase): + class Meta: + abstract = True + + +class SoftDeletablePolymorphicQuerySet(SoftDeletableQuerySet, PolymorphicQuerySet): + pass + + +class SoftDeletablePolymorphicManager(SoftDeletableManager, PolymorphicManager): + queryset_class = SoftDeletablePolymorphicQuerySet diff --git a/evg-outbo-api/outbo/apps/core/parsers.py b/evg-outbo-api/outbo/apps/core/parsers.py new file mode 100755 index 0000000..113d90b --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/parsers.py @@ -0,0 +1,26 @@ +from djangorestframework_camel_case.parser import CamelCaseMultiPartParser +from formencode.variabledecode import variable_decode +from rest_framework import parsers +from rest_framework.parsers import MultiPartParser + + +class NestedMultiPartParser(MultiPartParser): + def parse(self, stream, media_type=None, parser_context=None): + result = super().parse( + stream=stream, media_type=media_type, parser_context=parser_context + ) + data = variable_decode(result.data) + files = variable_decode(result.files) + + return parsers.DataAndFiles(data, files) + + +class CamelCaseNestedMultiPartParser(CamelCaseMultiPartParser): + def parse(self, stream, media_type=None, parser_context=None): + result = super().parse( + stream=stream, media_type=media_type, parser_context=parser_context + ) + data = variable_decode(result.data) + files = variable_decode(result.files) + + return parsers.DataAndFiles(data, files) diff --git a/evg-outbo-api/outbo/apps/core/rules.py b/evg-outbo-api/outbo/apps/core/rules.py new file mode 100755 index 0000000..a6ddddc --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/rules.py @@ -0,0 +1,16 @@ +from rules import predicate + + +@predicate +def is_agency(user): + return user.is_authenticated and user.is_agency + + +@predicate +def is_client(user): + return user.is_authenticated and user.is_client + + +@predicate +def is_supplier(user): + return user.is_authenticated and user.is_supplier diff --git a/evg-outbo-api/outbo/apps/core/serializers.py b/evg-outbo-api/outbo/apps/core/serializers.py new file mode 100755 index 0000000..74ecd4c --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/serializers.py @@ -0,0 +1,39 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_polymorphic.serializers import ( + PolymorphicSerializer as BasePolymorphicSerializer, +) + + +class PolymorphicSerializer(BasePolymorphicSerializer): + resource_type_field_name = "type" + + def to_resource_type(self, model_or_instance): + return model_or_instance._meta.verbose_name + + +class EnumCharField(serializers.ChoiceField): + default_error_messages = {"invalid_choice": _('"{input}" is not a valid choice.')} + + def __init__(self, enum, **kwargs): + self.enum = enum + kwargs.setdefault("choices", [(item, item.value) for item in enum]) + super().__init__(**kwargs) + self.enum = enum + + def to_internal_value(self, value): + value = super().to_representation(value) + if not isinstance(value, self.enum): + try: + enum_name, enum_value = value.split(".", 1) + if enum_name == self.enum.__name__: + value = self.enum[enum_value] + except ValueError: + raise ValidationError(self.error_messages["invalid"], code="invalid") + return value + + def to_representation(self, value): + if isinstance(value, self.enum): + return str(value) + return value diff --git a/evg-outbo-api/outbo/apps/core/storages.py b/evg-outbo-api/outbo/apps/core/storages.py new file mode 100755 index 0000000..98abf8a --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/storages.py @@ -0,0 +1,13 @@ +from storages.backends.s3boto3 import S3Boto3Storage +from storages.utils import setting + + +class S3StaticStorage(S3Boto3Storage): + bucket_name = setting("AWS_STORAGE_BUCKET_STATIC_NAME") + default_acl = "public-read" + querystring_auth = False + + +class S3MediaStorage(S3Boto3Storage): + bucket_name = setting("AWS_STORAGE_BUCKET_MEDIA_NAME") + default_acl = None diff --git a/evg-outbo-api/outbo/apps/core/urls.py b/evg-outbo-api/outbo/apps/core/urls.py new file mode 100755 index 0000000..f893078 --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from outbo.apps.core.views import RootApiView +from outbo.apps.products.views import ExportBriefToPDFApiView + +urlpatterns = [ + path("", RootApiView.as_view(), name="root"), + path("briefs//export-to-pdf/", ExportBriefToPDFApiView.as_view(), name="export_brief_to_pdf"), +] diff --git a/evg-outbo-api/outbo/apps/core/utils.py b/evg-outbo-api/outbo/apps/core/utils.py new file mode 100755 index 0000000..09ba898 --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/utils.py @@ -0,0 +1,13 @@ +from django.utils import timezone + + +def get_now(): + return timezone.now() + + +def get_current_date(): + return timezone.localtime(get_now()).date() + + +def is_empty_set(tested_set): + return tested_set == set() diff --git a/evg-outbo-api/outbo/apps/core/views.py b/evg-outbo-api/outbo/apps/core/views.py new file mode 100755 index 0000000..b49684c --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/views.py @@ -0,0 +1,10 @@ +from django.conf import settings +from rest_framework import permissions, views, response + + +class RootApiView(views.APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + print(request) + return response.Response({"version": settings.APP_VERSION}) diff --git a/evg-outbo-api/outbo/apps/core/viewsets.py b/evg-outbo-api/outbo/apps/core/viewsets.py new file mode 100755 index 0000000..794c9cb --- /dev/null +++ b/evg-outbo-api/outbo/apps/core/viewsets.py @@ -0,0 +1,160 @@ +import six +from django.db import transaction +from django.db.models import Model +from rest_framework import status, mixins +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.viewsets import GenericViewSet +from rest_framework_extensions.mixins import ( + NestedViewSetMixin as BaseNestedViewSetMixin, +) +from rest_framework_extensions.settings import extensions_api_settings +from rules.contrib.rest_framework import ( + AutoPermissionViewSetMixin as BaseAutoPermissionViewSetMixin, +) + + +class AutoPermissionViewSetMixin(BaseAutoPermissionViewSetMixin): + permission_type_map = { + **BaseAutoPermissionViewSetMixin.permission_type_map, + "metadata": None, + } + + +class CreateModelMixin: + def create(self, request, *args, **kwargs): + if isinstance(request.data, list): + with transaction.atomic(): + response = [] + for item in request.data: + write_serializer = self.get_serializer(data=item) + write_serializer.is_valid(raise_exception=True) + instance = self.perform_create(serializer=write_serializer) + read_serializer = self.get_read_serializer(instance=instance) + response.append(read_serializer.data) + return Response(response, status=status.HTTP_201_CREATED) + + else: + write_serializer = self.get_serializer(data=request.data) + write_serializer.is_valid(raise_exception=True) + + instance = self.perform_create(serializer=write_serializer) + + read_serializer = self.get_read_serializer(instance=instance) + headers = self.get_success_headers(read_serializer.data) + + return Response( + read_serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def perform_create(self, *, serializer): + return serializer.save() + + def get_success_headers(self, data): + try: + return {"Location": str(data[api_settings.URL_FIELD_NAME])} + except (TypeError, KeyError): + return {} + + +class UpdateModelMixin: + def update(self, request, *args, **kwargs): + instance = self.get_object() + write_serializer = self.get_serializer( + instance, data=request.data, partial=False + ) + write_serializer.is_valid(raise_exception=True) + + instance = write_serializer.save() + + if getattr(instance, "_prefetched_objects_cache", None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + read_serializer = self.get_read_serializer(instance=instance) + + return Response(read_serializer.data) + + +class PartialUpdateModelMixin: + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + write_serializer = self.get_serializer( + instance, data=request.data, partial=True + ) + write_serializer.is_valid(raise_exception=True) + + instance = write_serializer.save() + + if getattr(instance, "_prefetched_objects_cache", None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + read_serializer = self.get_read_serializer(instance=instance) + + return Response(read_serializer.data) + + +class RetrieveModelMixin(mixins.RetrieveModelMixin): + pass + + +class ListModelMixin(mixins.ListModelMixin): + pass + + +class DestroyModelMixin(mixins.DestroyModelMixin): + pass + + +class MultipleSerializerViewSet(GenericViewSet): + serializer_class_set = None + serializer_map = { + "list": "List", + "retrieve": "Retrieve", + "create": "Create", + "partial_update": "Update", + } + + def get_serializer_class(self): + assert ( + self.serializer_class_set is not None + ), "serializer_class_set should be defined" + + if self.action not in self.serializer_map: + raise KeyError( + f"""serializer_map does not contain serializer for "{self.action}" method""" + ) + return getattr(self.serializer_class_set, self.serializer_map[self.action]) + + def get_read_serializer(self, *args, **kwargs): + assert ( + self.serializer_class_set is not None + ), "serializer_class_set should be defined" + if self.action != "create": + if getattr(self.serializer_class_set, "Retrieve", None) is None: + raise KeyError( + f"""serializer_class_set does not contain "Retrieve" serializer""" + ) + serializer_class = self.serializer_class_set.Retrieve + else: + if ( + getattr(self.serializer_class_set, "Retrieve", None) is None + and getattr(self.serializer_class_set, "AfterCreate", None) is None + ): + raise KeyError( + f"""serializer_class_set does not contain "Retrieve" nor "AfterCreate" serializer and is used in "create" action.""" + ) + if getattr(self.serializer_class_set, "AfterCreate", None) is not None: + serializer_class = self.serializer_class_set.AfterCreate + else: + serializer_class = self.serializer_class_set.Retrieve + + kwargs["context"] = self.get_serializer_context() + return serializer_class(*args, **kwargs) + + +class NestedViewSetMixin(BaseNestedViewSetMixin): + pass diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/__init__.py b/evg-outbo-api/outbo/apps/final_photobriefing/__init__.py new file mode 100755 index 0000000..72d47bf --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/__init__.py @@ -0,0 +1,11 @@ +from django.utils.translation import gettext_lazy as _ +from django.apps import AppConfig + + +class FinalPhotobriefingAppConfig(AppConfig): + name = "outbo.apps.final_photobriefing" + label = "final_photobriefing" + verbose_name = _("Final photo briefing") + + +default_app_config = "outbo.apps.final_photobriefing.FinalPhotobriefingAppConfig" diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/admin.py b/evg-outbo-api/outbo/apps/final_photobriefing/admin.py new file mode 100755 index 0000000..8c38f3f --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/enums.py b/evg-outbo-api/outbo/apps/final_photobriefing/enums.py new file mode 100755 index 0000000..7ceabe8 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/enums.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class PhotoType(Enum): + INVALID = "" + ARRANGEMENT_PHOTOS = "arrangement" + CLIPPING = "clipping" + PACKSHOT = "packshot" + EXTRA_PHOTO = "extraPhoto" diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0001_initial.py new file mode 100755 index 0000000..2dccff9 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 2.2.8 on 2019-12-18 13:54 + +import rules.contrib.models +from django.db import migrations, models + +import outbo.apps.core.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="FinalBrief", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0002_remove_finalbrief_id.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0002_remove_finalbrief_id.py new file mode 100755 index 0000000..7203d64 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0002_remove_finalbrief_id.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.8 on 2019-12-18 14:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0001_initial"), + ] + + operations = [ + migrations.RemoveField(model_name="finalbrief", name="id",), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0003_finalbrief_brief.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0003_finalbrief_brief.py new file mode 100755 index 0000000..2593e89 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0003_finalbrief_brief.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.8 on 2019-12-18 14:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("photobriefing", "0001_initial"), + ("final_photobriefing", "0002_remove_finalbrief_id"), + ] + + operations = [ + migrations.AddField( + model_name="finalbrief", + name="brief", + field=models.ForeignKey( + default=0, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + serialize=False, + to="photobriefing.Brief", + ), + preserve_default=False, + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0004_auto_20191218_1505.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0004_auto_20191218_1505.py new file mode 100755 index 0000000..aade747 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0004_auto_20191218_1505.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.8 on 2019-12-18 14:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0003_finalbrief_brief"), + ] + + operations = [ + migrations.AlterField( + model_name="finalbrief", + name="brief", + field=models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + serialize=False, + to="photobriefing.Brief", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0005_auto_20191218_1506.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0005_auto_20191218_1506.py new file mode 100755 index 0000000..8cfb56f --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0005_auto_20191218_1506.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.8 on 2019-12-18 14:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0004_auto_20191218_1505"), + ] + + operations = [ + migrations.AlterField( + model_name="finalbrief", + name="brief", + field=models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="final", + related_query_name="final", + serialize=False, + to="photobriefing.Brief", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0006_finalbriefphoto.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0006_finalbriefphoto.py new file mode 100755 index 0000000..e7ddae8 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0006_finalbriefphoto.py @@ -0,0 +1,102 @@ +# Generated by Django 2.2.8 on 2019-12-19 08:48 + +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields +import outbo.apps.final_photobriefing.enums +import outbo.apps.final_photobriefing.models +import rules.contrib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0005_auto_20191218_1506"), + ] + + operations = [ + migrations.CreateModel( + name="FinalBriefPhoto", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "file", + models.FileField( + upload_to=outbo.apps.final_photobriefing.models.FormatFinalPhotoBriefPhotoFilename() + ), + ), + ( + "category", + models.CharField( + choices=[ + ( + outbo.apps.final_photobriefing.enums.PhotoType( + "" + ), + "", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType( + "arrangement" + ), + "arrangement", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType( + "clipping" + ), + "clipping", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType( + "packshot" + ), + "packshot", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType( + "extraPhoto" + ), + "extraPhoto", + ), + ], + max_length=30, + ), + ), + ( + "brief", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="final_photobriefing.FinalBrief", + ), + ), + ], + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0007_auto_20191220_1114.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0007_auto_20191220_1114.py new file mode 100755 index 0000000..e1a5314 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0007_auto_20191220_1114.py @@ -0,0 +1,56 @@ +# Generated by Django 2.2.8 on 2019-12-20 10:14 + +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.final_photobriefing.enums + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0006_finalbriefphoto"), + ] + + operations = [ + migrations.RemoveField(model_name="finalbriefphoto", name="category",), + migrations.AddField( + model_name="finalbriefphoto", + name="type", + field=models.CharField( + choices=[ + ( + outbo.apps.final_photobriefing.enums.PhotoType(""), + "", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType("arrangement"), + "arrangement", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType("clipping"), + "clipping", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType("packshot"), + "packshot", + ), + ( + outbo.apps.final_photobriefing.enums.PhotoType("extraPhoto"), + "extraPhoto", + ), + ], + default=outbo.apps.final_photobriefing.enums.PhotoType(""), + max_length=30, + ), + ), + migrations.AlterField( + model_name="finalbriefphoto", + name="brief", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="photos", + related_query_name="photo", + to="final_photobriefing.FinalBrief", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0008_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0008_auto_20200525_1025.py new file mode 100755 index 0000000..31c741d --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/0008_auto_20200525_1025.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("final_photobriefing", "0007_auto_20191220_1114"), + ] + + operations = [ + migrations.AlterField( + model_name="finalbrief", + name="brief", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="final", + related_query_name="final", + serialize=False, + to="photobriefing.Brief", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/migrations/__init__.py b/evg-outbo-api/outbo/apps/final_photobriefing/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/models.py b/evg-outbo-api/outbo/apps/final_photobriefing/models.py new file mode 100755 index 0000000..49c8c63 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/models.py @@ -0,0 +1,63 @@ +import os + +from django.db import models +from django.utils.deconstruct import deconstructible +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.final_photobriefing import rules +from outbo.apps.final_photobriefing.enums import PhotoType + + +@deconstructible +class FormatFinalPhotoBriefPhotoFilename: + def __call__(self, instance, filename): + obj = instance.brief.brief + return "/".join( + ("projects", obj.product.project.slug[1:], "final-photobriefing", filename) + ) + + +class FinalBrief(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_final_brief, + "view": rules.can_read_final_brief, + "change": rules.can_update_final_brief, + "delete": rules.can_delete_final_brief, + } + + brief = models.OneToOneField( + to="photobriefing.Brief", + on_delete=models.CASCADE, + related_name="final", + related_query_name="final", + primary_key=True, + ) + + +class FinalBriefPhoto(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_final_brief_photo, + "view": rules.can_read_final_brief_photo, + "change": rules.can_update_final_brief_photo, + "delete": rules.can_delete_final_brief_photo, + } + + brief = models.ForeignKey( + to=FinalBrief, + on_delete=models.CASCADE, + related_query_name="photo", + related_name="photos", + ) + file = models.FileField(upload_to=FormatFinalPhotoBriefPhotoFilename()) + type = models.CharField( + max_length=30, + choices=[(type, type.value) for type in PhotoType], + default=PhotoType.INVALID, + ) + + @property + def filename(self): + return os.path.basename(self.file.name) diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/rules.py b/evg-outbo-api/outbo/apps/final_photobriefing/rules.py new file mode 100755 index 0000000..f618a7e --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/rules.py @@ -0,0 +1,13 @@ +from rules import always_deny, always_allow + +from outbo.apps.core.rules import is_agency + +can_create_final_brief = always_deny +can_read_final_brief = always_allow +can_update_final_brief = is_agency +can_delete_final_brief = always_deny + +can_create_final_brief_photo = always_deny +can_read_final_brief_photo = always_allow +can_update_final_brief_photo = is_agency +can_delete_final_brief_photo = is_agency diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/urls.py b/evg-outbo-api/outbo/apps/final_photobriefing/urls.py new file mode 100755 index 0000000..c1767b0 --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/urls.py @@ -0,0 +1,13 @@ +from rest_framework_extensions.routers import ExtendedSimpleRouter + +from outbo.apps.final_photobriefing.viewsets import ( + FinalBriefViewSet, + FinalBriefPhotoViewSet, +) + +app_name = "final_photobriefing" +router = ExtendedSimpleRouter() +router.register(prefix="final-photobriefing", viewset=FinalBriefViewSet) +router.register(prefix="final-photobriefing-photo", viewset=FinalBriefPhotoViewSet) + +urlpatterns = router.urls diff --git a/evg-outbo-api/outbo/apps/final_photobriefing/viewsets.py b/evg-outbo-api/outbo/apps/final_photobriefing/viewsets.py new file mode 100755 index 0000000..7df146e --- /dev/null +++ b/evg-outbo-api/outbo/apps/final_photobriefing/viewsets.py @@ -0,0 +1,99 @@ +from django.http import HttpResponseRedirect +from rest_framework import serializers +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + PartialUpdateModelMixin, + DestroyModelMixin, +) +from outbo.apps.final_photobriefing.enums import PhotoType +from outbo.apps.final_photobriefing.models import FinalBrief, FinalBriefPhoto + + +class FinalBriefPhotoSerializer: + class List(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="final_photobriefing:finalbriefphoto-detail" + ) + type = serializers.SerializerMethodField() + filename = serializers.ReadOnlyField() + + class Meta: + model = FinalBriefPhoto + fields = ["url", "type", "filename"] + + def get_type(self, instance): + t, val = instance.type.split(".", 1) + return PhotoType[val].value + + class Create(serializers.ModelSerializer): + type = serializers.CharField() + + class Meta: + model = FinalBriefPhoto + fields = ["type", "file"] + + def validate_type(self, value): + try: + return PhotoType(value) + except ValueError: + raise serializers.ValidationError("Invalid photo type value") + + +class BaseFinalBriefSerializer(serializers.ModelSerializer): + class Meta: + model = FinalBrief + + +class FinalBriefSerializer: + class Retrieve(BaseFinalBriefSerializer): + photos = FinalBriefPhotoSerializer.List(many=True) + + class Meta(BaseFinalBriefSerializer.Meta): + fields = ["photos", "created_at", "updated_at"] + + class Update(BaseFinalBriefSerializer): + photos = FinalBriefPhotoSerializer.Create(many=True) + + class Meta(BaseFinalBriefSerializer.Meta): + fields = ["photos"] + + def update(self, instance, validated_data): + photos = validated_data.pop("photos", None) + if photos is not None: + for photo in photos: + FinalBriefPhoto.objects.create(brief=instance, **photo) + return instance + + +class FinalBriefViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +): + queryset = FinalBrief.objects.all() + serializer_class_set = FinalBriefSerializer + + +class FinalBriefPhotoViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + DestroyModelMixin, + MultipleSerializerViewSet, +): + queryset = FinalBriefPhoto.objects.all() + serializer_class_set = FinalBriefPhotoSerializer + retrieve_permission_classes = [AllowAny] + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + photo = self.get_object() + return HttpResponseRedirect(redirect_to=photo.file.url) diff --git a/evg-outbo-api/outbo/apps/organizations/__init__.py b/evg-outbo-api/outbo/apps/organizations/__init__.py new file mode 100755 index 0000000..9dfc360 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/__init__.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class OrganizationsAppConfig(AppConfig): + name = "outbo.apps.organizations" + label = "organizations" + verbose_name = _("Organizations") + + +default_app_config = "outbo.apps.organizations.OrganizationsAppConfig" diff --git a/evg-outbo-api/outbo/apps/organizations/admin.py b/evg-outbo-api/outbo/apps/organizations/admin.py new file mode 100755 index 0000000..85d7072 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/admin.py @@ -0,0 +1,57 @@ +from django.contrib import admin +from polymorphic.admin import ( + PolymorphicChildModelAdmin, + PolymorphicChildModelFilter, +) + +from outbo.apps.core.admin import PolymorphicParentModelAdmin +from outbo.apps.organizations.models import ( + Organization, + AgencyOrganization, + ClientOrganization, + SupplierOrganization, + Brand, + Category, +) + + +class _OrganizationChildAdmin(PolymorphicChildModelAdmin): + base_model = Organization + + +@admin.register(AgencyOrganization) +class AgencyOrganizationAdmin(_OrganizationChildAdmin): + base_model = AgencyOrganization + + +@admin.register(ClientOrganization) +class ClientOrganizationAdmin(_OrganizationChildAdmin): + base_model = ClientOrganization + search_fields = ["name"] + + +@admin.register(SupplierOrganization) +class SupplierOrganizationAdmin(_OrganizationChildAdmin): + base_model = SupplierOrganization + search_fields = ["name"] + + +@admin.register(Organization) +class OrganizationAdmin(PolymorphicParentModelAdmin): + base_model = Organization + child_models = (AgencyOrganization, ClientOrganization, SupplierOrganization) + list_filter = (PolymorphicChildModelFilter,) + search_fields = ["name"] + + +@admin.register(Brand) +class BrandAdmin(admin.ModelAdmin): + autocomplete_fields = ["client", "suppliers", "categories"] + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + search_fields = ["name"] + + +# NOTE: https://stackoverflow.com/questions/9879687/adding-a-manytomanywidget-to-the-reverse-of-a-manytomanyfield-in-the-django-admi/13189954 diff --git a/evg-outbo-api/outbo/apps/organizations/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/organizations/migrations/0001_initial.py new file mode 100755 index 0000000..1b2cd5a --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/migrations/0001_initial.py @@ -0,0 +1,220 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("name", models.CharField(max_length=254, verbose_name="name")), + ], + options={ + "verbose_name": "category", + "verbose_name_plural": "categories", + "ordering": ["name"], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Organization", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("name", models.CharField(max_length=100)), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_organizations.organization_set+", + to="contenttypes.ContentType", + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="AgencyOrganization", + fields=[ + ( + "organization_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="organizations.Organization", + ), + ), + ], + options={"verbose_name": "agency", "verbose_name_plural": "agencies",}, + bases=("organizations.organization",), + ), + migrations.CreateModel( + name="ClientOrganization", + fields=[ + ( + "organization_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="organizations.Organization", + ), + ), + ("uses_simple_project_flow", models.BooleanField(default=False)), + ], + options={"verbose_name": "client", "verbose_name_plural": "clients",}, + bases=("organizations.organization",), + ), + migrations.CreateModel( + name="SupplierOrganization", + fields=[ + ( + "organization_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="organizations.Organization", + ), + ), + ], + options={"verbose_name": "supplier", "verbose_name_plural": "suppliers",}, + bases=("organizations.organization",), + ), + migrations.CreateModel( + name="Brand", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("name", models.CharField(max_length=50, verbose_name="name")), + ( + "categories", + models.ManyToManyField( + related_name="brands", + related_query_name="brand", + to="organizations.Category", + verbose_name="categories", + ), + ), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="brands", + related_query_name="brand", + to="organizations.ClientOrganization", + verbose_name="client", + ), + ), + ( + "suppliers", + models.ManyToManyField( + related_name="brands", + related_query_name="brand", + to="organizations.SupplierOrganization", + verbose_name="suppliers", + ), + ), + ], + options={"verbose_name": "brand", "verbose_name_plural": "brands",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/organizations/migrations/0002_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/organizations/migrations/0002_auto_20200525_1025.py new file mode 100755 index 0000000..d9f078b --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/migrations/0002_auto_20200525_1025.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="brand", + name="client", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="brands", + related_query_name="brand", + to="organizations.ClientOrganization", + verbose_name="client", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/organizations/migrations/__init__.py b/evg-outbo-api/outbo/apps/organizations/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/organizations/models/__init__.py b/evg-outbo-api/outbo/apps/organizations/models/__init__.py new file mode 100755 index 0000000..eea600d --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/models/__init__.py @@ -0,0 +1,8 @@ +from .organization import ( + Organization, + AgencyOrganization, + ClientOrganization, + SupplierOrganization, +) +from .brand import Brand +from .category import Category diff --git a/evg-outbo-api/outbo/apps/organizations/models/brand.py b/evg-outbo-api/outbo/apps/organizations/models/brand.py new file mode 100755 index 0000000..91bd11a --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/models/brand.py @@ -0,0 +1,38 @@ +from django.utils.translation import gettext_lazy as _ +from django.db import models +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.organizations import rules + + +class Brand(TimestampableDeletableMixin, RulesModel): + class Meta: + verbose_name = _("brand") + verbose_name_plural = _("brands") + rules_permissions = {"read": rules.can_read_brand} + + name = models.CharField(verbose_name=_("name"), max_length=50) + client = models.ForeignKey( + verbose_name=_("client"), + to="ClientOrganization", + on_delete=models.CASCADE, + related_name="brands", + related_query_name="brand", + null=False, + ) + suppliers = models.ManyToManyField( + verbose_name=_("suppliers"), + to="SupplierOrganization", + related_name="brands", + related_query_name="brand", + ) + categories = models.ManyToManyField( + verbose_name=_("categories"), + to="Category", + related_name="brands", + related_query_name="brand", + ) + + def __str__(self): + return self.name diff --git a/evg-outbo-api/outbo/apps/organizations/models/category.py b/evg-outbo-api/outbo/apps/organizations/models/category.py new file mode 100755 index 0000000..addd14b --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/models/category.py @@ -0,0 +1,17 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin + + +class Category(TimestampableDeletableMixin, RulesModel): + class Meta: + verbose_name = _("category") + verbose_name_plural = _("categories") + ordering = ["name"] + + name = models.CharField(verbose_name=_("name"), max_length=254) + + def __str__(self): + return self.name diff --git a/evg-outbo-api/outbo/apps/organizations/models/organization.py b/evg-outbo-api/outbo/apps/organizations/models/organization.py new file mode 100755 index 0000000..9171191 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/models/organization.py @@ -0,0 +1,50 @@ +from django.utils.translation import gettext_lazy as _ +from django.db import models + +from outbo.apps.core.models import ( + RulesPolymorphicModel, + TimestampableDeletableMixin, + SoftDeletablePolymorphicManager, +) + + +class Organization(TimestampableDeletableMixin, RulesPolymorphicModel): + objects = SoftDeletablePolymorphicManager() + all_objects = SoftDeletablePolymorphicManager(alive_only=False) + + name = models.CharField(max_length=100) + + def __str__(self): + return f"[{str(self.polymorphic_ctype).title()}] {self.name}" + + @property + def type(self): + return self.polymorphic_ctype + + @type.setter + def type(self, value): + self.polymorphic_ctype = value + + @type.deleter + def type(self): + del self.polymorphic_ctype + + +class AgencyOrganization(Organization): + class Meta: + verbose_name = _("agency") + verbose_name_plural = _("agencies") + + +class ClientOrganization(Organization): + class Meta: + verbose_name = _("client") + verbose_name_plural = _("clients") + + uses_simple_project_flow = models.BooleanField(null=False, default=False) + + +class SupplierOrganization(Organization): + class Meta: + verbose_name = _("supplier") + verbose_name_plural = _("suppliers") diff --git a/evg-outbo-api/outbo/apps/organizations/rules.py b/evg-outbo-api/outbo/apps/organizations/rules.py new file mode 100755 index 0000000..7daa089 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/rules.py @@ -0,0 +1,11 @@ +from rules import always_deny, predicate + + +@predicate +def can_read_brand(user, brand): + return always_deny + + +@predicate +def can_read_category(user, category): + return always_deny diff --git a/evg-outbo-api/outbo/apps/organizations/serializers.py b/evg-outbo-api/outbo/apps/organizations/serializers.py new file mode 100755 index 0000000..1935b35 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/serializers.py @@ -0,0 +1,94 @@ +from rest_framework import serializers + +from outbo.apps.core.serializers import PolymorphicSerializer +from outbo.apps.organizations.models import ( + AgencyOrganization, + ClientOrganization, + SupplierOrganization, + Brand, + Category, + Organization, +) + + +# Organizations +class _BaseOrganizationSerializer(serializers.ModelSerializer): + class Meta: + fields = ( + "id", + "name", + ) + + +class _OrganizationSerializer: + class List(_BaseOrganizationSerializer): + class Meta(_BaseOrganizationSerializer.Meta): + model = Organization + + +class _AgencyOrganizationSerializer: + class List(_BaseOrganizationSerializer): + class Meta(_BaseOrganizationSerializer.Meta): + model = AgencyOrganization + + +class _ClientOrganizationSerializer: + class List(_BaseOrganizationSerializer): + class Meta(_BaseOrganizationSerializer.Meta): + model = ClientOrganization + + +class _SupplierOrganizationSerializer: + class List(_BaseOrganizationSerializer): + class Meta(_BaseOrganizationSerializer.Meta): + model = SupplierOrganization + + +class _BaseOrganizationPolymorphicSerializer(PolymorphicSerializer): + model_serializer_mapping = { + AgencyOrganization: _AgencyOrganizationSerializer.List, + ClientOrganization: _ClientOrganizationSerializer.List, + SupplierOrganization: _SupplierOrganizationSerializer.List, + Organization: _OrganizationSerializer.List, + } + + +class OrganizationSerializer: + class Retrieve(_BaseOrganizationPolymorphicSerializer): + pass + + class List(_BaseOrganizationPolymorphicSerializer): + pass + + +# Brands +class _BaseBrandSerializer(serializers.ModelSerializer): + class Meta: + model = Brand + fields = ["name"] + + +class BrandSerializer(serializers.ModelSerializer): + # Create & Update not needed + class Retrieve(_BaseBrandSerializer): + pass + + class List(_BaseBrandSerializer): + class Meta(_BaseBrandSerializer.Meta): + fields = ["id"] + _BaseBrandSerializer.Meta.fields + + +# Categories +class _BaseCategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["name"] + + +class CategorySerializer: + class Retrieve(_BaseCategorySerializer): + pass + + class List(_BaseCategorySerializer): + class Meta(_BaseCategorySerializer.Meta): + fields = ["id"] + _BaseCategorySerializer.Meta.fields diff --git a/evg-outbo-api/outbo/apps/organizations/urls.py b/evg-outbo-api/outbo/apps/organizations/urls.py new file mode 100755 index 0000000..5c985fc --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/urls.py @@ -0,0 +1,10 @@ +from rest_framework import routers + +from outbo.apps.organizations.viewsets import BrandViewSet, CategoryViewSet + +app_name = "organizations" +router = routers.SimpleRouter() +router.register(prefix="brands", viewset=BrandViewSet) +router.register(prefix="categories", viewset=CategoryViewSet) + +urlpatterns = router.urls diff --git a/evg-outbo-api/outbo/apps/organizations/viewsets.py b/evg-outbo-api/outbo/apps/organizations/viewsets.py new file mode 100755 index 0000000..17b4cd7 --- /dev/null +++ b/evg-outbo-api/outbo/apps/organizations/viewsets.py @@ -0,0 +1,29 @@ +from outbo.apps.core.viewsets import ( + MultipleSerializerViewSet, + AutoPermissionViewSetMixin, + ListModelMixin, + RetrieveModelMixin, +) +from outbo.apps.organizations.models import Brand, Category +from outbo.apps.organizations.serializers import BrandSerializer, CategorySerializer + + +class BrandViewSet( + RetrieveModelMixin, + ListModelMixin, + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, +): + queryset = Brand.objects.all() + serializer_class_set = BrandSerializer + + +class CategoryViewSet( + RetrieveModelMixin, + ListModelMixin, + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, +): + + queryset = Category.objects.all() + serializer_class_set = CategorySerializer diff --git a/evg-outbo-api/outbo/apps/photobriefing/__init__.py b/evg-outbo-api/outbo/apps/photobriefing/__init__.py new file mode 100755 index 0000000..76ffabb --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/__init__.py @@ -0,0 +1,11 @@ +from django.utils.translation import gettext_lazy as _ +from django.apps import AppConfig + + +class PhotobriefingAppConfig(AppConfig): + name = "outbo.apps.photobriefing" + label = "photobriefing" + verbose_name = _("Photo briefing") + + +default_app_config = "outbo.apps.photobriefing.PhotobriefingAppConfig" diff --git a/evg-outbo-api/outbo/apps/photobriefing/admin.py b/evg-outbo-api/outbo/apps/photobriefing/admin.py new file mode 100755 index 0000000..7950577 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from nested_admin import nested + +from outbo.apps.photobriefing.models import Brief, BriefItem, BriefItemPhoto, Category + + +class BriefItemPhotoInline(nested.NestedStackedInline): + model = BriefItemPhoto + + +class BriefItemInline(nested.NestedStackedInline): + model = BriefItem + inlines = [BriefItemPhotoInline] + + +@admin.register(Brief) +class BriefAdmin(nested.NestedModelAdmin): + inlines = [BriefItemInline] + + +admin.site.register(Category) diff --git a/evg-outbo-api/outbo/apps/photobriefing/enums.py b/evg-outbo-api/outbo/apps/photobriefing/enums.py new file mode 100755 index 0000000..4570ed9 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/enums.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ChoiceRole(Enum): + INVALID = "" + SUPPLIER = "supplier" + CLIENT = "client" diff --git a/evg-outbo-api/outbo/apps/photobriefing/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/photobriefing/migrations/0001_initial.py new file mode 100755 index 0000000..c7bc57f --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/migrations/0001_initial.py @@ -0,0 +1,304 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion +import django_fsm +import outbo.apps.core.fields +import outbo.apps.core.utils +import outbo.apps.photobriefing.enums +import outbo.apps.photobriefing.models.brief_item_photo +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("products", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Brief", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "product", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="photo_brief", + related_query_name="photo_brief", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + ( + "session_date", + models.DateField( + default=outbo.apps.core.utils.get_current_date, + verbose_name="photo session date", + ), + ), + ("concept_description", models.TextField(blank=True, default="")), + ( + "stage", + django_fsm.FSMField( + default="created", max_length=50, protected=True + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="BriefItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("title", models.CharField(max_length=100)), + ("description", models.TextField()), + ( + "brief", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="items", + related_query_name="item", + to="photobriefing.Brief", + verbose_name="brief", + ), + ), + ], + options={"ordering": ["id"],}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Category", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "label", + models.CharField( + max_length=30, + primary_key=True, + serialize=False, + verbose_name="label", + ), + ), + ( + "description", + models.CharField(max_length=30, verbose_name="description"), + ), + ], + options={"verbose_name": "category", "verbose_name_plural": "categories",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="BriefItemPhoto", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "file", + models.ImageField( + upload_to=outbo.apps.photobriefing.models.brief_item_photo.filename, + verbose_name="file", + ), + ), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="photos", + related_query_name="photo", + to="photobriefing.BriefItem", + verbose_name="brief item", + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="briefitem", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="photobriefing.Category", + verbose_name="category", + ), + ), + migrations.CreateModel( + name="BriefItemChoice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "role", + models.CharField( + choices=[ + ( + outbo.apps.photobriefing.enums.ChoiceRole(""), + "", + ), + ( + outbo.apps.photobriefing.enums.ChoiceRole("supplier"), + "supplier", + ), + ( + outbo.apps.photobriefing.enums.ChoiceRole("client"), + "client", + ), + ], + default=outbo.apps.photobriefing.enums.ChoiceRole(""), + editable=False, + max_length=30, + ), + ), + ( + "brief", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="choices", + related_query_name="choice", + to="photobriefing.Brief", + verbose_name="brief", + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="choices+", + related_query_name="choice", + to="photobriefing.Category", + verbose_name="category", + ), + ), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="choices", + related_query_name="choice", + to="photobriefing.BriefItem", + verbose_name="item", + ), + ), + ], + options={"unique_together": {("brief", "category", "role")},}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/photobriefing/migrations/0002_auto_20191218_1505.py b/evg-outbo-api/outbo/apps/photobriefing/migrations/0002_auto_20191218_1505.py new file mode 100755 index 0000000..0f07f92 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/migrations/0002_auto_20191218_1505.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.8 on 2019-12-18 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("photobriefing", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="brief", + name="concept_description", + field=models.TextField(blank=True), + ), + ] diff --git a/evg-outbo-api/outbo/apps/photobriefing/migrations/0003_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/photobriefing/migrations/0003_auto_20200525_1025.py new file mode 100755 index 0000000..8c0f861 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/migrations/0003_auto_20200525_1025.py @@ -0,0 +1,92 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("photobriefing", "0002_auto_20191218_1505"), + ] + + operations = [ + migrations.AlterField( + model_name="brief", + name="product", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="photo_brief", + related_query_name="photo_brief", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="briefitem", + name="brief", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + related_query_name="item", + to="photobriefing.Brief", + verbose_name="brief", + ), + ), + migrations.AlterField( + model_name="briefitem", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="photobriefing.Category", + verbose_name="category", + ), + ), + migrations.AlterField( + model_name="briefitemchoice", + name="brief", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + related_query_name="choice", + to="photobriefing.Brief", + verbose_name="brief", + ), + ), + migrations.AlterField( + model_name="briefitemchoice", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices+", + related_query_name="choice", + to="photobriefing.Category", + verbose_name="category", + ), + ), + migrations.AlterField( + model_name="briefitemchoice", + name="item", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + related_query_name="choice", + to="photobriefing.BriefItem", + verbose_name="item", + ), + ), + migrations.AlterField( + model_name="briefitemphoto", + name="item", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="photos", + related_query_name="photo", + to="photobriefing.BriefItem", + verbose_name="brief item", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/photobriefing/migrations/__init__.py b/evg-outbo-api/outbo/apps/photobriefing/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/__init__.py b/evg-outbo-api/outbo/apps/photobriefing/models/__init__.py new file mode 100755 index 0000000..ec5a13d --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/__init__.py @@ -0,0 +1,5 @@ +from .brief import Brief +from .brief_item import BriefItem +from .brief_item_photo import BriefItemPhoto +from .brief_item_choice import BriefItemChoice +from .category import Category diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/brief.py b/evg-outbo-api/outbo/apps/photobriefing/models/brief.py new file mode 100755 index 0000000..728fa37 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/brief.py @@ -0,0 +1,107 @@ +from itertools import product + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_fsm import FSMField, transition +from rules.contrib.models import RulesModel + +from outbo.apps.activity_log.enums import MessageType +from outbo.apps.activity_log.models import ProductLogItem +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.core.utils import get_current_date, is_empty_set +from outbo.apps.final_photobriefing.models import FinalBrief +from outbo.apps.photobriefing import rules +from outbo.apps.photobriefing.enums import ChoiceRole + + +class Brief(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_brief, + "view": rules.can_read_brief, + "change": rules.can_update_brief, + "delete": rules.can_delete_brief, + } + + product = models.OneToOneField( + verbose_name=_("product"), + to="products.Product", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="photo_brief", + related_query_name="photo_brief", + ) + + session_date = models.DateField( + verbose_name=_("photo session date"), + blank=False, + null=False, + default=get_current_date, + ) + + concept_description = models.TextField(blank=True, null=False) + + stage = FSMField(default="created", protected=True) + + def save(self, *args, **kwargs): + result = super().save(*args, **kwargs) + if self.product: + self.product.save() + return result + + def acceptable_for_user(self, *, user): + if user.is_observer or user.is_trader: + return False + return not user.is_agency and self.stage == "published" + + def publishable_for_user(self, *, user): + if user.is_observer or user.is_trader: + return False + all_categories = set(self.items.values_list("category", flat=True)) + if self.editable_for_user(user=user) and not is_empty_set(all_categories): + return True + return False + + def completable_for_user(self, *, user): + if user.is_observer or user.is_trader: + return False + if not user.is_agency or self.stage != "published": + return False + + roles = [ChoiceRole.SUPPLIER, ChoiceRole.CLIENT] + all_categories = set(self.items.values_list("category", flat=True)) + if is_empty_set(all_categories): + return False + + for category, role in product(all_categories, roles): + if not self.choices.filter(category_id=category, role=role).exists(): + return False + + return True + + def editable_for_user(self, *, user): + if user.is_observer or user.is_trader: + return False + if not user.is_agency or not self.stage == "created": + return False + return self.product.stage.group in [2, 3] + + @transition(field="stage", source="created", target="published") + def publish_as_user(self, *, user): + ProductLogItem.objects.create( + product=self.product, + created_by=user, + message="Photobriefing published", + message_type=MessageType.SUCCESS, + ) + + @transition(field="stage", source="published", target="completed") + def complete_as_user(self, *, user): + FinalBrief.objects.create(brief=self) + ProductLogItem.objects.create( + product=self.product, + created_by=user, + message="Photobriefing completed", + message_type=MessageType.SUCCESS, + ) diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/brief_item.py b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item.py new file mode 100755 index 0000000..73f5c45 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item.py @@ -0,0 +1,32 @@ +from django.db import models +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from django.utils.translation import gettext_lazy as _ + +from outbo.apps.photobriefing import rules + + +class BriefItem(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_brief_item, + "view": rules.can_read_brief_item, + "change": rules.can_update_brief_item, + "delete": rules.can_delete_brief_item, + } + ordering = ["id"] + + brief = models.ForeignKey( + verbose_name=_("brief"), + to="Brief", + on_delete=models.CASCADE, + related_name="items", + related_query_name="item", + ) + category = models.ForeignKey( + verbose_name=_("category"), to="Category", on_delete=models.CASCADE + ) + + title = models.CharField(max_length=100, blank=False, null=False) + description = models.TextField(blank=False, null=False) diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_choice.py b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_choice.py new file mode 100755 index 0000000..9f055fb --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_choice.py @@ -0,0 +1,39 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.photobriefing.enums import ChoiceRole + + +class BriefItemChoice(TimestampableDeletableMixin, RulesModel): + class Meta: + unique_together = (("brief", "category", "role"),) + + brief = models.ForeignKey( + verbose_name=_("brief"), + to="Brief", + on_delete=models.CASCADE, + related_name="choices", + related_query_name="choice", + ) + category = models.ForeignKey( + verbose_name=_("category"), + to="Category", + on_delete=models.CASCADE, + related_name="choices+", + related_query_name="choice", + ) + role = models.CharField( + max_length=30, + choices=[(role, role.value) for role in ChoiceRole], + default=ChoiceRole.INVALID, + editable=False, + ) + item = models.ForeignKey( + verbose_name=_("item"), + to="BriefItem", + on_delete=models.CASCADE, + related_name="choices", + related_query_name="choice", + ) diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_photo.py b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_photo.py new file mode 100755 index 0000000..a11ae26 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/brief_item_photo.py @@ -0,0 +1,40 @@ +import os + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules import always_allow +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin + + +def filename(self, filename): + return "/".join( + ( + "projects", + self.item.brief.product.project.slug[1:], + "photobriefing", + filename, + ) + ) + + +class BriefItemPhoto(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "view": always_allow, + } + + item = models.ForeignKey( + verbose_name=_("brief item"), + to="BriefItem", + on_delete=models.CASCADE, + related_name="photos", + related_query_name="photo", + ) + + file = models.ImageField(verbose_name=_("file"), upload_to=filename) + + @property + def filename(self): + return os.path.basename(self.file.name) diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/category.py b/evg-outbo-api/outbo/apps/photobriefing/models/category.py new file mode 100755 index 0000000..01840f1 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/category.py @@ -0,0 +1,17 @@ +from django.db import models +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from django.utils.translation import gettext_lazy as _, pgettext + + +class Category(TimestampableDeletableMixin, RulesModel): + class Meta: + verbose_name = _("category") + verbose_name_plural = _("categories") + + label = models.CharField(verbose_name=_("label"), max_length=30, primary_key=True) + description = models.CharField(verbose_name=_("description"), max_length=30) + + def __str__(self): + return pgettext("photo_brief_categories", self.description) diff --git a/evg-outbo-api/outbo/apps/photobriefing/models/photo.py b/evg-outbo-api/outbo/apps/photobriefing/models/photo.py new file mode 100755 index 0000000..e53ab9b --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/models/photo.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.final_photobriefing.enums import PhotoType + + +class Photo(TimestampableDeletableMixin, RulesModel): + brief = models.ForeignKey( + verbose_name=_("photo brief"), + to="Brief", + on_delete=models.CASCADE, + related_name="photos", + related_query_name="photo", + ) + + type = models.CharField( + max_length=50, + choices=((type, type.value) for type in PhotoType), + default=PhotoType.INVALID, + ) + + def filename(self, filename): + return f"projects/{self.brief.product.project.slug[1:]}/photos/{filename}" + + file = models.ImageField(verbose_name=_("file"), upload_to=filename) diff --git a/evg-outbo-api/outbo/apps/photobriefing/rules.py b/evg-outbo-api/outbo/apps/photobriefing/rules.py new file mode 100755 index 0000000..44ac395 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/rules.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +from rules import always_deny, predicate + +from outbo.apps.core.rules import is_agency +from outbo.apps.products.rules import can_read_product + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + from outbo.apps.photobriefing.models import Brief, BriefItem + + +# Brief +@predicate +def _has_readable_product(user: "User", brief: "Brief"): + return can_read_product.test(user, brief.product) + + +@predicate +def can_edit_brief(user: "User", brief: "Brief"): + return brief.editable_for_user(user=user) + + +@predicate +def can_publish_brief(user: "User", brief: "Brief"): + return brief.publishable_for_user(user=user) + + +@predicate +def can_complete_brief(user: "User", brief: "Brief"): + return brief.completable_for_user(user=user) + + +@predicate +def can_accept_brief(user: "User", brief: "Brief"): + return brief.acceptable_for_user(user=user) + + +# CRUD +can_create_brief = always_deny +can_read_brief = _has_readable_product +can_update_brief = ( + can_edit_brief | can_publish_brief | can_accept_brief | can_complete_brief +) +can_delete_brief = always_deny + + +# BriefItem +@predicate +def _has_readable_brief(user: "User", brief_item: "BriefItem"): + return can_read_brief.test(user, brief_item.brief) + + +@predicate +def _has_editable_brief(user: "User", brief_item: "BriefItem"): + return can_edit_brief.test(user, brief_item.brief) + + +# CRUD +can_create_brief_item = is_agency +can_read_brief_item = _has_readable_brief +can_update_brief_item = always_deny +can_delete_brief_item = _has_editable_brief diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/__init__.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/__init__.py new file mode 100755 index 0000000..f3a81ab --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/__init__.py @@ -0,0 +1,5 @@ +from .brief_item_photo import BriefItemPhotoSerializer +from .brief_item import BriefItemSerializer +from .brief_item_choice import BriefItemChoiceSerializer +from .brief import BriefSerializer +from .category import CategorySerializer diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/brief.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief.py new file mode 100755 index 0000000..5077943 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief.py @@ -0,0 +1,159 @@ +from typing import TYPE_CHECKING + +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied + +from outbo.apps.photobriefing.enums import ChoiceRole +from outbo.apps.photobriefing.models import Brief, BriefItemChoice +from outbo.apps.photobriefing.rules import can_complete_brief, can_publish_brief +from outbo.apps.photobriefing.serializers import ( + BriefItemSerializer, + BriefItemChoiceSerializer, +) + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class BaseBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Brief + + def get_editable(self, instance: Brief): + current_user = self.context["request"].user + return instance.editable_for_user(user=current_user) + + def get_acceptable(self, instance: Brief): + current_user = self.context["request"].user + return instance.acceptable_for_user(user=current_user) + + def get_publishable(self, instance: Brief): + current_user = self.context["request"].user + return instance.publishable_for_user(user=current_user) + + def get_completable(self, instance: Brief): + current_user = self.context["request"].user + return instance.completable_for_user(user=current_user) + + +class BriefSerializer: + class Retrieve(BaseBriefSerializer): + items = BriefItemSerializer.List(many=True) + choices = BriefItemChoiceSerializer.List(many=True) + editable = serializers.SerializerMethodField() + acceptable = serializers.SerializerMethodField() + publishable = serializers.SerializerMethodField() + completable = serializers.SerializerMethodField() + + class Meta(BaseBriefSerializer.Meta): + fields = [ + "stage", + "session_date", + "concept_description", + "items", + "choices", + "created_at", + "updated_at", + "editable", + "acceptable", + "publishable", + "completable", + ] + + class Update(BaseBriefSerializer): + publish = serializers.BooleanField() + complete = serializers.BooleanField() + choice = BriefItemChoiceSerializer.Update(partial=False) + items = BriefItemSerializer.Create(many=True) + + class Meta(BaseBriefSerializer.Meta): + fields = [ + "session_date", + "concept_description", + "items", + "choice", + "publish", + "complete", + ] + + def update(self, instance, validated_data): + if validated_data.get("choice", None) is not None: + serializer = BriefSerializer.ChoiceUpdate(context=self.context) + return serializer.update(instance, {"choice": validated_data["choice"]}) + if validated_data.get("publish", None) is not None: + serializer = BriefSerializer.Publish(context=self.context) + return serializer.update( + instance, {"publish": validated_data["publish"]} + ) + if validated_data.get("complete", None) is not None: + serializer = BriefSerializer.Complete(context=self.context) + return serializer.update( + instance, {"complete": validated_data["complete"]} + ) + items = validated_data.pop("items", None) + if items: + for item in items: + item["brief"] = instance + BriefItemSerializer.Create(context=self.context).create(item) + + return super().update(instance=instance, validated_data=validated_data) + + class Publish(BaseBriefSerializer): + publish = serializers.BooleanField() + + class Meta(BaseBriefSerializer.Meta): + fields = ["publish"] + + def update(self, instance, validated_data): + publish = validated_data["publish"] + current_user = self.context["request"].user + if not can_publish_brief.test(current_user, instance): + raise PermissionDenied() + if publish: + instance.publish_as_user(user=current_user) + instance.save() + return instance + + class Complete(BaseBriefSerializer): + complete = serializers.BooleanField() + + class Meta(BaseBriefSerializer.Meta): + fields = ["complete"] + + def update(self, instance: Brief, validated_data): + complete = validated_data["complete"] + current_user = self.context["request"].user + if not can_complete_brief.test(current_user, instance): + raise PermissionDenied() + if complete: + instance.complete_as_user(user=current_user) + instance.save() + return instance + + class ChoiceUpdate(BaseBriefSerializer): + choice = BriefItemChoiceSerializer.Update() + + class Meta(BaseBriefSerializer.Meta): + fields = ["choice"] + + def update(self, instance, validated_data): + choice = validated_data["choice"] + current_user: "User" = self.context["request"].user + if current_user.is_supplier: + role = ChoiceRole.SUPPLIER + elif current_user.is_client: + role = ChoiceRole.CLIENT + else: + raise NotImplemented + + current_choice, _ = BriefItemChoice.objects.get_or_create( + defaults={"item": choice["item"]}, + brief=instance, + category=choice["category"], + role=role, + ) + if current_choice.item != choice["item"]: + current_choice.item = choice["item"] + current_choice.save() + + return instance diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item.py new file mode 100755 index 0000000..83a2301 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from outbo.apps.photobriefing.models import BriefItem +from outbo.apps.photobriefing.serializers import BriefItemPhotoSerializer + + +class BaseBriefItemSerializer(serializers.ModelSerializer): + class Meta: + model = BriefItem + + +class BriefItemSerializer: + class List(BaseBriefItemSerializer): + photos = BriefItemPhotoSerializer.List(many=True) + + class Meta(BaseBriefItemSerializer.Meta): + fields = ["id", "category", "title", "description", "photos"] + + class Create(BaseBriefItemSerializer): + photos = BriefItemPhotoSerializer.Create(many=True) + + class Meta(BaseBriefItemSerializer.Meta): + fields = ["category", "title", "description", "photos"] + + def create(self, validated_data): + photos = validated_data.pop("photos", None) + instance = super().create(validated_data) + if photos is not None: + for photo in photos: + photo["item"] = instance + BriefItemPhotoSerializer.Create().create(photo) + return instance + + class AfterCreate(BaseBriefItemSerializer): + photos = BriefItemPhotoSerializer.List(many=True) + + class Meta(BaseBriefItemSerializer.Meta): + fields = ["id", "category", "title", "description", "photos"] diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_choice.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_choice.py new file mode 100755 index 0000000..0f5a275 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_choice.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from outbo.apps.photobriefing.enums import ChoiceRole +from outbo.apps.photobriefing.models import BriefItemChoice + + +class BaseBriefItemChoiceSerializer(serializers.ModelSerializer): + class Meta: + model = BriefItemChoice + + +class BriefItemChoiceSerializer: + class List(BaseBriefItemChoiceSerializer): + role = serializers.SerializerMethodField() + + class Meta(BaseBriefItemChoiceSerializer.Meta): + fields = ["category", "item", "role"] + + def get_role(self, instance): + role = eval(instance.role) # TODO: eval is evil + if role is ChoiceRole.SUPPLIER: + return "supplier" + if role is ChoiceRole.CLIENT: + return "client" + raise NotImplementedError + + class Update(BaseBriefItemChoiceSerializer): + class Meta: + model = BriefItemChoice + fields = ["category", "item"] diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_photo.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_photo.py new file mode 100755 index 0000000..c98b22d --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/brief_item_photo.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from outbo.apps.photobriefing.models import BriefItemPhoto + + +class BaseBriefItemPhotoSerializer(serializers.ModelSerializer): + class Meta: + model = BriefItemPhoto + + +class BriefItemPhotoSerializer: + class Create(BaseBriefItemPhotoSerializer): + class Meta(BaseBriefItemPhotoSerializer.Meta): + fields = ["file"] + + class List(BaseBriefItemPhotoSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="photobriefing:briefitemphoto-detail", read_only=True + ) + filename = serializers.ReadOnlyField() + + class Meta(BaseBriefItemPhotoSerializer.Meta): + fields = ["url", "filename"] diff --git a/evg-outbo-api/outbo/apps/photobriefing/serializers/category.py b/evg-outbo-api/outbo/apps/photobriefing/serializers/category.py new file mode 100755 index 0000000..0cb8937 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/serializers/category.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from outbo.apps.photobriefing.models import Category + + +class CategorySerializer: + class List(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["label", "description"] diff --git a/evg-outbo-api/outbo/apps/photobriefing/urls.py b/evg-outbo-api/outbo/apps/photobriefing/urls.py new file mode 100755 index 0000000..e89c698 --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/urls.py @@ -0,0 +1,24 @@ +from rest_framework_extensions.routers import ExtendedSimpleRouter + +from outbo.apps.photobriefing.viewsets import ( + BriefViewSet, + BriefItemNestedViewSet, + CategoryViewSet, + BriefItemPhotoViewSet, + BriefItemViewSet, +) + +app_name = "photobriefing" +router = ExtendedSimpleRouter() +router.register(prefix="photobriefing-categories", viewset=CategoryViewSet) +router.register(prefix="photobriefing-items", viewset=BriefItemViewSet) +router.register(prefix="photobriefing", viewset=BriefViewSet).register( + prefix="items", + viewset=BriefItemNestedViewSet, + basename="brief-item", + parents_query_lookups=["brief_id"], +) + +router.register(prefix="photobriefing-photos", viewset=BriefItemPhotoViewSet) + +urlpatterns = router.urls diff --git a/evg-outbo-api/outbo/apps/photobriefing/viewsets.py b/evg-outbo-api/outbo/apps/photobriefing/viewsets.py new file mode 100755 index 0000000..4857a9f --- /dev/null +++ b/evg-outbo-api/outbo/apps/photobriefing/viewsets.py @@ -0,0 +1,82 @@ +from django.http import HttpResponseRedirect +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + PartialUpdateModelMixin, + NestedViewSetMixin, + CreateModelMixin, + ListModelMixin, + DestroyModelMixin, +) +from outbo.apps.photobriefing import rules +from outbo.apps.photobriefing.models import Brief, BriefItem, Category, BriefItemPhoto +from outbo.apps.photobriefing.serializers import ( + BriefSerializer, + BriefItemSerializer, + CategorySerializer, + BriefItemPhotoSerializer, +) + + +class BriefViewSet( + AutoPermissionViewSetMixin, + PartialUpdateModelMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, +): + queryset = Brief.objects.all() + serializer_class_set = BriefSerializer + + +class BriefItemViewSet( + AutoPermissionViewSetMixin, DestroyModelMixin, MultipleSerializerViewSet, +): + queryset = BriefItem.objects.all() + serializer_class_set = BriefItemSerializer + + +class BriefItemNestedViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, +): + queryset = BriefItem.objects.all() + serializer_class_set = BriefItemSerializer + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + brief = Brief.objects.get(pk=parents["brief_id"]) + if not rules.can_edit_brief.test(current_user, brief): + raise PermissionDenied() # NOTE: Should it be here? + + return serializer.save(brief=brief) + + +class BriefItemPhotoViewSet( + AutoPermissionViewSetMixin, RetrieveModelMixin, MultipleSerializerViewSet, +): + queryset = BriefItemPhoto.objects.all() + serializer_class_set = BriefItemPhotoSerializer + retrieve_permission_classes = [AllowAny] + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + photo: BriefItemPhoto = self.get_object() + return HttpResponseRedirect(redirect_to=photo.file.url) + + +class CategoryViewSet( + AutoPermissionViewSetMixin, ListModelMixin, MultipleSerializerViewSet, +): + queryset = Category.objects.all() + serializer_class_set = CategorySerializer diff --git a/evg-outbo-api/outbo/apps/products/__init__.py b/evg-outbo-api/outbo/apps/products/__init__.py new file mode 100755 index 0000000..1b5ea16 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/__init__.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class ProductsAppConfig(AppConfig): + name = "outbo.apps.products" + label = "products" + verbose_name = _("Products") + + +default_app_config = "outbo.apps.products.ProductsAppConfig" diff --git a/evg-outbo-api/outbo/apps/products/admin.py b/evg-outbo-api/outbo/apps/products/admin.py new file mode 100755 index 0000000..fb68eb7 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin +from nested_admin import nested + + +from outbo.apps.core.admin import ( + NestedTabularSingleItemInline, + NestedStackedSingleItemInline, +) +from outbo.apps.products.models import ( + ProductStage, + PackagingType, + Product, + DraftFile, + FirstFinalFile, + PrintingFilesFile, + BriefConfig, + Brief, + Photo, + Blanking, +) + +admin.site.register(PackagingType) +admin.site.register(ProductStage) + + +class PhotoInline(nested.NestedStackedInline): + model = Photo + readonly_fields = ["brief"] + extra = 0 + + +class BlankingInline(nested.NestedStackedInline): + model = Blanking + readonly_fields = ["brief"] + extra = 0 + + +class BriefConfigInline(NestedTabularSingleItemInline): + model = BriefConfig + readonly_fields = ["brief"] + can_delete = False + + +class BriefInline(NestedStackedSingleItemInline): + model = Brief + readonly_fields = ["product"] + can_delete = False + inlines = [PhotoInline, BlankingInline, BriefConfigInline] + + +@admin.register(Product) +class ProductAdmin(nested.NestedModelAdmin): + readonly_fields = ["stage"] + search_fields = ["project__name"] + inlines = [BriefInline] + + +admin.site.register(DraftFile) +admin.site.register(FirstFinalFile) +admin.site.register(PrintingFilesFile) diff --git a/evg-outbo-api/outbo/apps/products/enums.py b/evg-outbo-api/outbo/apps/products/enums.py new file mode 100755 index 0000000..bf6bdae --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/enums.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class FileType(Enum): + INVALID = "" + DRAFT = "Draft file" + FIRST_FINAL = "First final file" + PRINTING_FILE = "Printing file" diff --git a/evg-outbo-api/outbo/apps/products/filters.py b/evg-outbo-api/outbo/apps/products/filters.py new file mode 100755 index 0000000..224e7de --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/filters.py @@ -0,0 +1,9 @@ +from django_filters import rest_framework as filters + +from outbo.apps.products.models import Product + + +class ProductFilter(filters.FilterSet): + class Meta: + model = Product + fields = ("project",) diff --git a/evg-outbo-api/outbo/apps/products/managers.py b/evg-outbo-api/outbo/apps/products/managers.py new file mode 100755 index 0000000..5b0a87b --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/managers.py @@ -0,0 +1,17 @@ +from outbo.apps.core.models import SoftDeletableManager +from outbo.apps.products.enums import FileType + + +class DraftFileManager(SoftDeletableManager): + def get_queryset(self): + return super().get_queryset().filter(file_type=FileType.DRAFT) + + +class FirstFinalFileManager(SoftDeletableManager): + def get_queryset(self): + return super().get_queryset().filter(file_type=FileType.FIRST_FINAL) + + +class PrintingFileManager(SoftDeletableManager): + def get_queryset(self): + return super().get_queryset().filter(file_type=FileType.PRINTING_FILE) diff --git a/evg-outbo-api/outbo/apps/products/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/products/migrations/0001_initial.py new file mode 100755 index 0000000..76c1b03 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0001_initial.py @@ -0,0 +1,795 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields +import outbo.apps.products.models.blanking +import outbo.apps.products.models.design_stage_file +import outbo.apps.products.models.photo +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0001_initial"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DesignStageFile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "version", + models.CharField( + editable=False, max_length=3, verbose_name="version" + ), + ), + ("description", models.TextField()), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_products.designstagefile_set+", + to="contenttypes.ContentType", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="uploaded by", + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="PackagingType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("name", models.CharField(max_length=200, verbose_name="name")), + ], + options={ + "verbose_name": "packaging type", + "verbose_name_plural": "packaging types", + "ordering": ["name"], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="products", + related_query_name="product", + to="projects.Project", + verbose_name="project", + ), + ), + ], + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="ProductStage", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "id", + models.CharField( + max_length=50, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=2, verbose_name="label")), + ("group", models.PositiveSmallIntegerField(verbose_name="group")), + ( + "description", + models.CharField(max_length=254, verbose_name="description"), + ), + ( + "next_action", + models.CharField(max_length=254, verbose_name="next action"), + ), + ("order", models.PositiveSmallIntegerField(verbose_name="order")), + ], + options={"ordering": ["label"],}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Brief", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "product", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="brief", + related_query_name="brief", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + ( + "name_pol", + models.CharField( + blank=True, max_length=200, verbose_name="product name (PL)" + ), + ), + ( + "name_eng", + models.CharField( + blank=True, max_length=200, verbose_name="product name (EN)" + ), + ), + ( + "model_no", + models.CharField( + blank=True, max_length=200, verbose_name="model number" + ), + ), + ( + "batch_no", + models.CharField( + blank=True, max_length=200, verbose_name="batch number" + ), + ), + ( + "ean", + models.CharField(blank=True, max_length=13, verbose_name="EAN"), + ), + ( + "main_description", + models.TextField(blank=True, verbose_name="main description"), + ), + ( + "extra_description", + models.TextField(blank=True, verbose_name="extra description"), + ), + ( + "certificate", + models.TextField(blank=True, verbose_name="certificate"), + ), + ( + "information_signs", + models.TextField(blank=True, verbose_name="information signs"), + ), + ( + "capabilities_icons", + models.TextField(blank=True, verbose_name="capabilities icons"), + ), + ( + "product_icons", + models.TextField(blank=True, verbose_name="product icons"), + ), + ( + "has_description_eng", + models.BooleanField(default=False, verbose_name="description (EN)"), + ), + ( + "has_description_pol", + models.BooleanField(default=False, verbose_name="description (PL)"), + ), + ( + "has_description_por", + models.BooleanField(default=False, verbose_name="description (PT)"), + ), + ( + "has_description_rus", + models.BooleanField(default=False, verbose_name="description (RU)"), + ), + ( + "has_description_spa", + models.BooleanField(default=False, verbose_name="description (ES)"), + ), + ( + "illustration", + models.TextField(blank=True, verbose_name="illustration"), + ), + ( + "cleaning_method", + models.TextField(blank=True, verbose_name="cleaning method"), + ), + ( + "country_of_origin", + models.TextField(blank=True, verbose_name="country of origin"), + ), + ("manual", models.TextField(blank=True, verbose_name="manual")), + ("material", models.TextField(blank=True, verbose_name="material")), + ("warning", models.TextField(blank=True, verbose_name="warning")), + ( + "arrangement_photo", + models.TextField(blank=True, verbose_name="arrangement photo"), + ), + ("packshot", models.TextField(blank=True, verbose_name="packshot")), + ("clipping", models.TextField(blank=True, verbose_name="clippings")), + ( + "extra_photo", + models.TextField(blank=True, verbose_name="extra photo"), + ), + ( + "packaging_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + related_query_name="+", + to="products.PackagingType", + verbose_name="packaging type", + ), + ), + ], + options={"verbose_name": "brief", "verbose_name_plural": "briefs",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Draft", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "product", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="draft", + related_query_name="draft", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + ], + options={"verbose_name": "draft", "verbose_name_plural": "drafts",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="FirstFinal", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "product", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="first_final", + related_query_name="first_final", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + ], + options={ + "verbose_name": "first final", + "verbose_name_plural": "first finals", + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="PrintingFiles", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "product", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="printing_files", + related_query_name="printing_files", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + ], + options={ + "verbose_name": "printing files", + "verbose_name_plural": "printing files", + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name="product", + name="stage", + field=outbo.apps.core.fields.FSMKeyField( + default="waiting_for_brief", + on_delete=django.db.models.deletion.PROTECT, + protected=True, + to="products.ProductStage", + verbose_name="stage", + ), + ), + migrations.CreateModel( + name="BriefConfig", + fields=[ + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "brief", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + primary_key=True, + related_name="config", + related_query_name="config", + serialize=False, + to="products.Brief", + ), + ), + ( + "illustration", + models.BooleanField(default=False, verbose_name="illustrations"), + ), + ( + "cleaning_method", + models.BooleanField(default=False, verbose_name="cleaning method"), + ), + ( + "country_of_origin", + models.BooleanField( + default=False, verbose_name="country of origin" + ), + ), + ("manual", models.BooleanField(default=False, verbose_name="manual")), + ( + "material", + models.BooleanField(default=False, verbose_name="material"), + ), + ( + "warning", + models.BooleanField(default=False, verbose_name="warnings"), + ), + ( + "photobrief", + models.BooleanField(default=False, verbose_name="photobrief"), + ), + ( + "packshot", + models.BooleanField(default=False, verbose_name="packshot"), + ), + ( + "clipping", + models.BooleanField(default=False, verbose_name="clipping"), + ), + ( + "extra_photo", + models.BooleanField(default=False, verbose_name="extra photo"), + ), + ], + options={ + "verbose_name": "brief configuration", + "verbose_name_plural": "brief configurations", + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="PrintingFilesFile", + fields=[ + ( + "designstagefile_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="products.DesignStageFile", + ), + ), + ( + "file", + models.FileField( + upload_to=outbo.apps.products.models.design_stage_file.FormatFilename( + dir_name="first_finals", + filename_prefix="FF", + parent_name="first_final", + ), + verbose_name="file", + ), + ), + ( + "printing_files", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="files", + related_query_name="file", + to="products.PrintingFiles", + verbose_name="printing files", + ), + ), + ], + options={ + "verbose_name": "printing file", + "verbose_name_plural": "printing files", + }, + bases=("products.designstagefile",), + ), + migrations.CreateModel( + name="Photo", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "file", + models.ImageField( + upload_to=outbo.apps.products.models.photo.Photo.product_photo_filename, + verbose_name="file", + ), + ), + ( + "brief", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="photos", + related_query_name="photo", + to="products.Brief", + verbose_name="brief", + ), + ), + ], + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="FirstFinalFile", + fields=[ + ( + "designstagefile_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="products.DesignStageFile", + ), + ), + ( + "file", + models.FileField( + upload_to=outbo.apps.products.models.design_stage_file.FormatFilename( + dir_name="first_finals", + filename_prefix="FF", + parent_name="first_final", + ), + verbose_name="file", + ), + ), + ( + "first_final", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="files", + related_query_name="file", + to="products.FirstFinal", + verbose_name="first final", + ), + ), + ], + options={ + "verbose_name": "first final file", + "verbose_name_plural": "first final files", + }, + bases=("products.designstagefile",), + ), + migrations.CreateModel( + name="DraftFile", + fields=[ + ( + "designstagefile_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="products.DesignStageFile", + ), + ), + ( + "file", + models.FileField( + upload_to=outbo.apps.products.models.design_stage_file.FormatFilename( + dir_name="drafts", + filename_prefix="DRAFT", + parent_name="draft", + ), + verbose_name="file", + ), + ), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="files", + related_query_name="file", + to="products.Draft", + verbose_name="draft", + ), + ), + ], + options={ + "verbose_name": "draft file", + "verbose_name_plural": "draft files", + }, + bases=("products.designstagefile",), + ), + migrations.CreateModel( + name="Blanking", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "file", + models.FileField( + upload_to=outbo.apps.products.models.blanking.product_blanking_filename, + verbose_name="file", + ), + ), + ( + "brief", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="blankings", + related_query_name="blanking", + to="products.Brief", + verbose_name="brief", + ), + ), + ], + options={"verbose_name": "blanking", "verbose_name_plural": "blankings",}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0002_draft_max_version.py b/evg-outbo-api/outbo/apps/products/migrations/0002_draft_max_version.py new file mode 100755 index 0000000..db67b3e --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0002_draft_max_version.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-29 12:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="draft", + name="max_version", + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0003_auto_20191129_1354.py b/evg-outbo-api/outbo/apps/products/migrations/0003_auto_20191129_1354.py new file mode 100755 index 0000000..d4debbc --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0003_auto_20191129_1354.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.7 on 2019-11-29 12:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0002_draft_max_version"), + ] + + operations = [ + migrations.RenameField( + model_name="designstagefile", old_name="uploaded_by", new_name="created_by", + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0004_auto_20191203_1421.py b/evg-outbo-api/outbo/apps/products/migrations/0004_auto_20191203_1421.py new file mode 100755 index 0000000..ef1061c --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0004_auto_20191203_1421.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.7 on 2019-12-03 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0003_auto_20191129_1354"), + ] + + operations = [ + migrations.AlterModelOptions( + name="draftfile", + options={ + "ordering": ["-created_at"], + "verbose_name": "draft file", + "verbose_name_plural": "draft files", + }, + ), + migrations.AddField( + model_name="firstfinal", + name="max_version", + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AddField( + model_name="printingfiles", + name="max_version", + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0005_auto_20200211_1405.py b/evg-outbo-api/outbo/apps/products/migrations/0005_auto_20200211_1405.py new file mode 100755 index 0000000..6752da1 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0005_auto_20200211_1405.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.8 on 2020-02-11 13:05 + +from django.db import migrations, models +import outbo.apps.products.models.design_stage_file + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0004_auto_20191203_1421"), + ] + + operations = [ + migrations.AlterField( + model_name="printingfilesfile", + name="file", + field=models.FileField( + upload_to=outbo.apps.products.models.design_stage_file.FormatFilename( + dir_name="printing_files", + filename_prefix="PF", + parent_name="printing_files", + ), + verbose_name="file", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0006_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/products/migrations/0006_auto_20200525_1025.py new file mode 100755 index 0000000..f863dc9 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0006_auto_20200525_1025.py @@ -0,0 +1,201 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0005_auto_20200211_1405"), + ] + + operations = [ + migrations.AlterModelOptions( + name="firstfinalfile", + options={ + "ordering": ["-created_at"], + "verbose_name": "first final file", + "verbose_name_plural": "first final files", + }, + ), + migrations.AlterModelOptions( + name="printingfilesfile", + options={ + "ordering": ["-created_at"], + "verbose_name": "printing file", + "verbose_name_plural": "printing files", + }, + ), + migrations.AlterField( + model_name="blanking", + name="brief", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blankings", + related_query_name="blanking", + to="products.Brief", + verbose_name="brief", + ), + ), + migrations.AlterField( + model_name="brief", + name="packaging_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + related_query_name="+", + to="products.PackagingType", + verbose_name="packaging type", + ), + ), + migrations.AlterField( + model_name="brief", + name="product", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="brief", + related_query_name="brief", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="briefconfig", + name="brief", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="config", + related_query_name="config", + serialize=False, + to="products.Brief", + ), + ), + migrations.AlterField( + model_name="designstagefile", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="uploaded by", + ), + ), + migrations.AlterField( + model_name="draft", + name="product", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="draft", + related_query_name="draft", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="draftfile", + name="draft", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + related_query_name="file", + to="products.Draft", + verbose_name="draft", + ), + ), + migrations.AlterField( + model_name="firstfinal", + name="product", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="first_final", + related_query_name="first_final", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="firstfinalfile", + name="first_final", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + related_query_name="file", + to="products.FirstFinal", + verbose_name="first final", + ), + ), + migrations.AlterField( + model_name="photo", + name="brief", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="photos", + related_query_name="photo", + to="products.Brief", + verbose_name="brief", + ), + ), + migrations.AlterField( + model_name="printingfiles", + name="product", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="printing_files", + related_query_name="printing_files", + serialize=False, + to="products.Product", + verbose_name="product", + ), + ), + migrations.AlterField( + model_name="printingfilesfile", + name="printing_files", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + related_query_name="file", + to="products.PrintingFiles", + verbose_name="printing files", + ), + ), + migrations.AlterField( + model_name="product", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + related_query_name="product", + to="projects.Project", + verbose_name="project", + ), + ), + migrations.AlterField( + model_name="product", + name="stage", + field=outbo.apps.core.fields.FSMKeyField( + default="waiting_for_brief", + on_delete=django.db.models.deletion.CASCADE, + protected=True, + to="products.ProductStage", + verbose_name="stage", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0007_auto_20210518_1128.py b/evg-outbo-api/outbo/apps/products/migrations/0007_auto_20210518_1128.py new file mode 100755 index 0000000..aa20b5c --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0007_auto_20210518_1128.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2021-05-18 09:28 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0006_auto_20200525_1025'), + ] + + operations = [ + migrations.AddField( + model_name='draftfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=15), blank=True, null=True, size=None), + ), + migrations.AddField( + model_name='firstfinalfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=15), blank=True, null=True, size=None), + ), + migrations.AddField( + model_name='printingfilesfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=15), blank=True, null=True, size=None), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/0008_auto_20210518_1247.py b/evg-outbo-api/outbo/apps/products/migrations/0008_auto_20210518_1247.py new file mode 100755 index 0000000..af5b172 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/migrations/0008_auto_20210518_1247.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2021-05-18 10:47 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0007_auto_20210518_1128'), + ] + + operations = [ + migrations.AlterField( + model_name='draftfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None), + ), + migrations.AlterField( + model_name='firstfinalfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None), + ), + migrations.AlterField( + model_name='printingfilesfile', + name='opened_by', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None), + ), + ] diff --git a/evg-outbo-api/outbo/apps/products/migrations/__init__.py b/evg-outbo-api/outbo/apps/products/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/products/models/__init__.py b/evg-outbo-api/outbo/apps/products/models/__init__.py new file mode 100755 index 0000000..2b0111b --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/__init__.py @@ -0,0 +1,19 @@ +from .packaging_type import PackagingType +from .product_stage import ProductStage +from .brief import Brief +from .brief_config import BriefConfig + +from .draft import Draft +from .first_final import FirstFinal +from .printing_files import PrintingFiles + +from .product import Product + +from .blanking import Blanking +from .photo import Photo +from .design_stage_file import ( + DesignStageFile, + DraftFile, + FirstFinalFile, + PrintingFilesFile, +) diff --git a/evg-outbo-api/outbo/apps/products/models/blanking.py b/evg-outbo-api/outbo/apps/products/models/blanking.py new file mode 100755 index 0000000..1655437 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/blanking.py @@ -0,0 +1,41 @@ +import os + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules import always_allow +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + + +def product_blanking_filename(instance, filename): + return f"projects/{instance.brief.product.project.slug[1:]}/blankings/{filename}" + + +class Blanking(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "delete": rules.can_delete_blanking, + "view": always_allow, + } + verbose_name = _("blanking") + verbose_name_plural = _("blankings") + + brief = models.ForeignKey( + verbose_name=_("brief"), + to="Brief", + on_delete=models.CASCADE, + related_name="blankings", + related_query_name="blanking", + ) + + file = models.FileField(verbose_name=_("file"), upload_to=product_blanking_filename) + + @property + def filename(self): + return os.path.basename(self.file.name) + + @property + def size(self): + return self.file.size diff --git a/evg-outbo-api/outbo/apps/products/models/brief.py b/evg-outbo-api/outbo/apps/products/models/brief.py new file mode 100755 index 0000000..ebaa52d --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/brief.py @@ -0,0 +1,90 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class Brief(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_brief, + "delete": rules.can_delete_brief, + "change": rules.can_update_brief, + "view": rules.can_read_brief, + } + verbose_name = _("brief") + verbose_name_plural = _("briefs") + + product = models.OneToOneField( + verbose_name=_("product"), + to="Product", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="brief", + related_query_name="brief", + ) + name_pol = models.CharField(verbose_name=_("product name (PL)"), max_length=200, blank=True, null=False) + name_eng = models.CharField(verbose_name=_("product name (EN)"), max_length=200, blank=True, null=False) + model_no = models.CharField(verbose_name=_("model number"), max_length=200, blank=True, null=False) + batch_no = models.CharField(verbose_name=_("batch number"), max_length=200, blank=True, null=False) + ean = models.CharField(verbose_name=_("EAN"), max_length=13, blank=True, null=False) + packaging_type = models.ForeignKey( + verbose_name=_("packaging type"), + to="PackagingType", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="+", + related_query_name="+", + ) + main_description = models.TextField(verbose_name=_("main description"), blank=True, null=False) + extra_description = models.TextField(verbose_name=_("extra description"), blank=True, null=False) + certificate = models.TextField(verbose_name=_("certificate"), blank=True, null=False) + information_signs = models.TextField(verbose_name=_("information signs"), blank=True, null=False) + capabilities_icons = models.TextField(verbose_name=_("capabilities icons"), blank=True, null=False) + product_icons = models.TextField(verbose_name=_("product icons"), blank=True, null=False) + has_description_eng = models.BooleanField(verbose_name=_("description (EN)"), default=False, null=False) + has_description_pol = models.BooleanField(verbose_name=_("description (PL)"), default=False, null=False) + has_description_por = models.BooleanField(verbose_name=_("description (PT)"), default=False, null=False) + has_description_rus = models.BooleanField(verbose_name=_("description (RU)"), default=False, null=False) + has_description_spa = models.BooleanField(verbose_name=_("description (ES)"), default=False, null=False) + illustration = models.TextField(verbose_name=_("illustration"), blank=True, null=False) + cleaning_method = models.TextField(verbose_name=_("cleaning method"), blank=True, null=False) + country_of_origin = models.TextField(verbose_name=_("country of origin"), blank=True, null=False) + manual = models.TextField(verbose_name=_("manual"), blank=True, null=False) + material = models.TextField(verbose_name=_("material"), blank=True, null=False) + warning = models.TextField(verbose_name=_("warning"), blank=True, null=False) + arrangement_photo = models.TextField(verbose_name=_("arrangement photo"), blank=True, null=False) + packshot = models.TextField(verbose_name=_("packshot"), blank=True, null=False) + clipping = models.TextField(verbose_name=_("clippings"), blank=True, null=False) + extra_photo = models.TextField(verbose_name=_("extra photo"), blank=True, null=False) + + def __str__(self): + return f"""{self.product.project.slug} / {self.name_pol or ""}""" + + # serializer methods + + def editable_for_user(self, *, user: "User") -> bool: + if user.is_observer or user.is_trader: + return False + return user.is_supplier and self.product.stage.label == "1" + + def acceptable_for_user(self, *, user: "User") -> bool: + if user.is_observer or user.is_trader: + return False + return (user.is_agency and self.product.stage.pk == "waiting_for_brief_accept_by_agency") or ( + user.is_client and self.product.stage.pk == "waiting_for_brief_accept_by_client" + ) + + def publishable_for_user(self, *, user: "User") -> bool: + if user.is_observer or user.is_trader: + return False + return self.product.project.published and self.product.has_brief_publishable_for_user(user=user) diff --git a/evg-outbo-api/outbo/apps/products/models/brief_config.py b/evg-outbo-api/outbo/apps/products/models/brief_config.py new file mode 100755 index 0000000..cb22105 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/brief_config.py @@ -0,0 +1,60 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin + + +class BriefConfig(TimestampableDeletableMixin, RulesModel): + class Meta: + verbose_name = _("brief configuration") + verbose_name_plural = _("brief configurations") + + brief = models.OneToOneField( + to="Brief", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="config", + related_query_name="config", + ) + illustration = models.BooleanField( + verbose_name=_("illustrations"), default=False, null=False + ) + cleaning_method = models.BooleanField( + verbose_name=_("cleaning method"), default=False, null=False + ) + country_of_origin = models.BooleanField( + verbose_name=_("country of origin"), default=False, null=False + ) + manual = models.BooleanField(verbose_name=_("manual"), default=False, null=False) + material = models.BooleanField( + verbose_name=_("material"), default=False, null=False + ) + warning = models.BooleanField(verbose_name=_("warnings"), default=False, null=False) + photobrief = models.BooleanField( + verbose_name=_("photobrief"), default=False, null=False + ) + packshot = models.BooleanField( + verbose_name=_("packshot"), default=False, null=False + ) + clipping = models.BooleanField( + verbose_name=_("clipping"), default=False, null=False + ) + extra_photo = models.BooleanField( + verbose_name=_("extra photo"), default=False, null=False + ) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + result = super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + if self.brief: + self.brief.save(using=using) + + return result diff --git a/evg-outbo-api/outbo/apps/products/models/design_stage_file.py b/evg-outbo-api/outbo/apps/products/models/design_stage_file.py new file mode 100755 index 0000000..450270d --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/design_stage_file.py @@ -0,0 +1,190 @@ +import os + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.db.models import F +from django.utils.deconstruct import deconstructible +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ +from text_unidecode import unidecode + +from outbo.apps.core.models import ( + TimestampableDeletableMixin, + RulesPolymorphicModel, + SoftDeletablePolymorphicManager, +) +from outbo.apps.core.rules import is_agency +from outbo.apps.products import rules + + +@deconstructible +class FormatFilename: + def __init__(self, parent_name: str, filename_prefix: str, dir_name: str): + self.parent_name = parent_name + self.filename_prefix = filename_prefix + self.dir_name = dir_name + + def __call__(self, instance, filename): + obj = getattr(instance, self.parent_name) + filename = "-".join( + ( + self.filename_prefix, + obj.product.brief.model_no, + slugify(unidecode(obj.product.brief.name_pol)), + instance.version, + ) + ) + return "/".join( + ( + "projects", + obj.product.project.slug[1:], + self.dir_name, + f"{filename}.pdf", + ) + ) + + +class DesignStageFile(TimestampableDeletableMixin, RulesPolymorphicModel): + objects = SoftDeletablePolymorphicManager() + all_objects = SoftDeletablePolymorphicManager(alive_only=False) + + created_by = models.ForeignKey( + verbose_name=_("uploaded by"), + to="authentication.User", + on_delete=models.CASCADE, + related_name="+", + ) + version = models.CharField(verbose_name=_("version"), max_length=3, editable=False) + description = models.TextField() + + def save(self, *args, **kwargs): + parent_name = kwargs.pop("parent_name", None) + parent = getattr(self, parent_name) + + if self._state.adding is True and parent_name is not None: + if is_agency.test(self.created_by): + self.version = f"v{parent.max_version}" + parent.max_version = F("max_version") + 1 + parent.save() + else: + self.version = "P" + super().save(*args, **kwargs) + + +class DraftFile(DesignStageFile): + class Meta: + rules_permissions = { + "view": rules.can_read_draft_file, + "add": rules.can_create_draft_file, + "change": rules.can_update_draft_file, + "delete": rules.can_delete_draft_file, + } + verbose_name = _("draft file") + verbose_name_plural = _("draft files") + ordering = ["-created_at"] + + draft = models.ForeignKey( + verbose_name=_("draft"), + to="Draft", + on_delete=models.CASCADE, + related_name="files", + related_query_name="file", + ) + file = models.FileField( + verbose_name=_("file"), + upload_to=FormatFilename( + filename_prefix="DRAFT", dir_name="drafts", parent_name="draft" + ), + ) + opened_by = ArrayField(models.IntegerField(), null=True, blank=True) + + @property + def filename(self): + return os.path.basename(self.file.name) + + @property + def size(self): + return self.file.size + + def save(self, *args, **kwargs): + return super().save(*args, **kwargs, parent_name="draft") + + +class FirstFinalFile(DesignStageFile): + class Meta: + rules_permissions = { + "view": rules.can_read_ff_file, + "add": rules.can_create_ff_file, + "change": rules.can_update_ff_file, + "delete": rules.can_delete_ff_file, + } + verbose_name = _("first final file") + verbose_name_plural = _("first final files") + ordering = ["-created_at"] + + first_final = models.ForeignKey( + verbose_name=_("first final"), + to="FirstFinal", + on_delete=models.CASCADE, + related_name="files", + related_query_name="file", + ) + file = models.FileField( + verbose_name=_("file"), + upload_to=FormatFilename( + filename_prefix="FF", dir_name="first_finals", parent_name="first_final" + ), + ) + opened_by = ArrayField(models.IntegerField(), null=True, blank=True) + + @property + def filename(self): + return os.path.basename(self.file.name) + + @property + def size(self): + return self.file.size + + def save(self, *args, **kwargs): + return super().save(*args, **kwargs, parent_name="first_final") + + +class PrintingFilesFile(DesignStageFile): + class Meta: + rules_permissions = { + "view": rules.can_read_pf_file, + "add": rules.can_create_pf_file, + "change": rules.can_update_pf_file, + "delete": rules.can_delete_pf_file, + } + verbose_name = _("printing file") + verbose_name_plural = _("printing files") + ordering = ["-created_at"] + + printing_files = models.ForeignKey( + verbose_name=_("printing files"), + to="PrintingFiles", + on_delete=models.CASCADE, + related_name="files", + related_query_name="file", + ) + file = models.FileField( + verbose_name=_("file"), + upload_to=FormatFilename( + filename_prefix="PF", + dir_name="printing_files", + parent_name="printing_files", + ), + ) + opened_by = ArrayField(models.IntegerField(), null=True, blank=True) + + @property + def filename(self): + return os.path.basename(self.file.name) + + @property + def size(self): + return self.file.size + + def save(self, *args, **kwargs): + return super().save(*args, **kwargs, parent_name="printing_files") diff --git a/evg-outbo-api/outbo/apps/products/models/draft.py b/evg-outbo-api/outbo/apps/products/models/draft.py new file mode 100755 index 0000000..2b44462 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/draft.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rest_framework.reverse import reverse_lazy, reverse +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class Draft(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_draft, + "delete": rules.can_delete_draft, + "change": rules.can_update_draft, + "view": rules.can_read_draft, + } + verbose_name = _("draft") + verbose_name_plural = _("drafts") + + product = models.OneToOneField( + verbose_name=_("product"), + to="Product", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="draft", + related_query_name="draft", + ) + + # internals + + max_version = models.PositiveSmallIntegerField(default=1) + + def editable_for_user(self, *, user: "User") -> bool: + if user.is_observer or user.is_trader: + return False + if user.is_agency: + # initial publish + return self.product.stage.pk == "waiting_for_draft" + elif user.is_supplier: + # improvements from supplier + return self.product.stage.pk == "waiting_for_draft_accept_by_supplier" + if user.is_client: + # improvements from agency + return self.product.stage.pk in [ + "waiting_for_draft_accept_by_supplier", + "waiting_for_draft_accept_by_client", + ] + return False + + def acceptable_for_user(self, *, user: "User") -> bool: + if user.is_observer or user.is_trader: + return False + if user.is_supplier: + return self.product.stage.pk == "waiting_for_draft_accept_by_supplier" + if user.is_client: + return self.product.stage.pk == "waiting_for_draft_accept_by_client" + return False diff --git a/evg-outbo-api/outbo/apps/products/models/first_final.py b/evg-outbo-api/outbo/apps/products/models/first_final.py new file mode 100755 index 0000000..9c8936f --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/first_final.py @@ -0,0 +1,69 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class FirstFinal(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_ff, + "delete": rules.can_delete_ff, + "change": rules.can_update_ff, + "view": rules.can_read_ff, + } + verbose_name = _("first final") + verbose_name_plural = _("first finals") + + product = models.OneToOneField( + verbose_name=_("product"), + to="Product", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="first_final", + related_query_name="first_final", + ) + + # internals + + max_version = models.PositiveSmallIntegerField(default=1) + + # serializer methods + + def editable_for_user(self, user: "User"): + if user.is_observer or user.is_trader: + return False + if user.is_agency: + # initial publish + return self.product.stage.pk == "waiting_for_ff" + elif user.is_supplier: + # improvements from supplier + return ( + self.product.stage.pk == "waiting_for_ff" and self.files.count() > 0 + ) or self.product.stage.pk == "waiting_for_ff_accept_by_supplier" + if user.is_client: + # improvements from agency + return ( + self.product.stage.pk == "waiting_for_ff" and self.files.count() > 0 + ) or self.product.stage.pk in [ + "waiting_for_ff_accept_by_supplier", + "waiting_for_ff_accept_by_client", + ] + return False + + def acceptable_for_user(self, user: "User"): + if user.is_observer or user.is_trader: + return False + if user.is_supplier: + return self.product.stage.pk == "waiting_for_ff_accept_by_supplier" + if user.is_client: + return self.product.stage.pk == "waiting_for_ff_accept_by_client" + return False diff --git a/evg-outbo-api/outbo/apps/products/models/packaging_type.py b/evg-outbo-api/outbo/apps/products/models/packaging_type.py new file mode 100755 index 0000000..e45bcdb --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/packaging_type.py @@ -0,0 +1,18 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin + + +class PackagingType(TimestampableDeletableMixin, RulesModel): + class Meta: + verbose_name = _("packaging type") + verbose_name_plural = _("packaging types") + ordering = ["name"] + + name = models.CharField(verbose_name=_("name"), max_length=200) + + def __str__(self): + return self.name diff --git a/evg-outbo-api/outbo/apps/products/models/photo.py b/evg-outbo-api/outbo/apps/products/models/photo.py new file mode 100755 index 0000000..d405164 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/photo.py @@ -0,0 +1,31 @@ +import os + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules import always_allow +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + + +class Photo(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = {"view": always_allow, "delete": rules.can_delete_photo} + + brief = models.ForeignKey( + verbose_name=_("brief"), + to="Brief", + on_delete=models.CASCADE, + related_name="photos", + related_query_name="photo", + ) + + def product_photo_filename(self, filename): + return f"projects/{self.brief.product.project.slug[1:]}/photos/{filename}" + + file = models.ImageField(verbose_name=_("file"), upload_to=product_photo_filename) + + @property + def filename(self): + return os.path.basename(self.file.name) diff --git a/evg-outbo-api/outbo/apps/products/models/printing_files.py b/evg-outbo-api/outbo/apps/products/models/printing_files.py new file mode 100755 index 0000000..a33bf93 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/printing_files.py @@ -0,0 +1,69 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.products import rules + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class PrintingFiles(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "add": rules.can_create_pf, + "delete": rules.can_delete_pf, + "change": rules.can_update_pf, + "view": rules.can_read_pf, + } + verbose_name = _("printing files") + verbose_name_plural = _("printing files") + + product = models.OneToOneField( + verbose_name=_("product"), + to="Product", + on_delete=models.CASCADE, + primary_key=True, + editable=False, + related_name="printing_files", + related_query_name="printing_files", + ) + + # internals + + max_version = models.PositiveSmallIntegerField(default=1) + + # serializer methods + + def editable_for_user(self, user: "User"): + if user.is_observer or user.is_trader: + return False + if user.is_agency: + # initial publish + return self.product.stage.pk == "waiting_for_pf" + elif user.is_supplier: + # improvements from supplier + return ( + self.product.stage.pk == "waiting_for_pf" and self.files.count() > 0 + ) or self.product.stage.pk == "waiting_for_pf_accept_by_supplier" + if user.is_client: + # improvements from agency + return ( + self.product.stage.pk == "waiting_for_pf" and self.files.count() > 0 + ) or self.product.stage.pk in [ + "waiting_for_pf_accept_by_supplier", + "waiting_for_pf_accept_by_client", + ] + return False + + def acceptable_for_user(self, user: "User"): + if user.is_observer or user.is_trader: + return False + if user.is_supplier: + return self.product.stage.pk == "waiting_for_pf_accept_by_supplier" + if user.is_client: + return self.product.stage.pk == "waiting_for_pf_accept_by_client" + return False diff --git a/evg-outbo-api/outbo/apps/products/models/product.py b/evg-outbo-api/outbo/apps/products/models/product.py new file mode 100755 index 0000000..5c5db99 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/product.py @@ -0,0 +1,621 @@ +from functools import reduce + +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.utils.translation import gettext_lazy as _ +from django_fsm import transition, RETURN_VALUE +from outbo.apps.activity_log.enums import MessageType +from outbo.apps.activity_log.models import ProductLogItem +from outbo.apps.authentication.models import User +from outbo.apps.core.fields import FSMKeyField +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.photobriefing.models import Brief as PhotoBrief +from outbo.apps.products import rules +from outbo.apps.products.models import ( + ProductStage, + Draft, + Brief, + BriefConfig, + FirstFinal, + PrintingFiles, +) +from rules.contrib.models import RulesModel + + +class Product(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "retrieve": rules.can_create_product, + "view": rules.can_read_product, + "update": rules.can_update_product, + "partial_update": rules.can_update_product, + "delete": rules.can_delete_product, + } + + project = models.ForeignKey( + verbose_name=_("project"), + to="projects.Project", + on_delete=models.CASCADE, + related_name="products", + related_query_name="product", + null=False, + ) + stage = FSMKeyField( + verbose_name=_("stage"), + to="ProductStage", + on_delete=models.CASCADE, + default="waiting_for_brief", + protected=True, + ) + + def __str__(self): + return f"""{self.brief}""" + + @property + def display_name(self): + return ( + f"""{self.brief.model_no or ""} / {self.brief.name_pol or ""}""" + ) + + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + result = super().save( + force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields, + ) + if self.project: + self.project.save(using=using) + + def has_complete_brief(self): + conditions = [ + self.brief.name_pol, + self.brief.photos.count() > 0, + self.brief.blankings.count() > 0, + self.brief.name_eng, + self.brief.model_no, + self.brief.batch_no, + self.brief.ean, + self.brief.packaging_type, + self.brief.main_description, + self.brief.extra_description, + self.brief.certificate, + self.brief.information_signs, + self.brief.capabilities_icons, + self.brief.product_icons, + ] + if self.brief.config.illustration: + conditions += [self.brief.illustration] + if self.brief.config.cleaning_method: + conditions += [self.brief.cleaning_method] + if self.brief.config.country_of_origin: + conditions += [self.brief.country_of_origin] + if self.brief.config.manual: + conditions += [self.brief.manual] + if self.brief.config.material: + conditions += [self.brief.material] + if self.brief.config.warning: + conditions += [self.brief.warning] + if self.brief.config.photobrief: + conditions += [self.brief.arrangement_photo] + if self.brief.config.packshot: + conditions += [self.brief.packshot] + if self.brief.config.clipping: + conditions += [self.brief.clipping] + if self.brief.config.extra_photo: + conditions += [self.brief.extra_photo] + + return reduce(lambda accumulator, current: accumulator and bool(current), conditions, True) + + def has_brief_publishable_for_user(self, *, user: "User") -> bool: + stage = ProductStage.objects.get(id="waiting_for_brief") + return user.is_supplier and self.stage == stage and self.has_complete_brief() + + @transition( + field="stage", source="waiting_for_brief", target="waiting_for_brief_accept_by_agency", + ) + def publish_brief_as_user(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_brief") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Brief published", + message_type=MessageType.SUCCESS, + stage=stage, + ) + + def accept_brief_as_user(self, *, user: "User") -> str: + product_users = [member.user for member in self.project.members.all()] + product_ids = [member.user.id for member in self.project.members.all()] + traders = User.objects.filter(is_trader=True, category=self.project.category).exclude( + id__in=product_ids + ) + if self.stage.pk == "waiting_for_brief_accept_by_agency": + self._accept_brief_as_agency(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in traders and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + elif self.stage.pk == "waiting_for_brief_accept_by_client": + self._accept_brief_as_client(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + else: + raise NotImplementedError() + + @transition( + field="stage", + source="waiting_for_brief_accept_by_agency", + target="waiting_for_brief_accept_by_client", + ) + def _accept_brief_as_agency(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_brief_accept_by_agency") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Brief accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + return "waiting_for_brief_accept_by_client" + + @transition( + field="stage", source="waiting_for_brief_accept_by_client", target="waiting_for_draft", + ) + def _accept_brief_as_client(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_brief_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Brief accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + Draft.objects.create(product=self) + if self.brief.config.photobrief: + PhotoBrief.objects.create(product=self) + return "waiting_for_draft" + + def reject_brief_as_user(self, *, user: "User") -> None: + product_users = [member.user for member in self.project.members.all()] + if self.stage.pk == "waiting_for_brief_accept_by_agency": + self._reject_brief_as_agency(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_supplier, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + elif self.stage.pk == "waiting_for_brief_accept_by_client": + self._reject_brief_as_client(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_supplier, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + @transition( + field="stage", source="waiting_for_brief_accept_by_agency", target="waiting_for_brief", + ) + def _reject_brief_as_agency(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_brief_accept_by_agency") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Brief rejected", + message_type=MessageType.SUCCESS, + stage=stage, + ) + + @transition( + field="stage", source="waiting_for_brief_accept_by_client", target="waiting_for_brief", + ) + def _reject_brief_as_client(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_brief_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Brief rejected", + message_type=MessageType.SUCCESS, + stage=stage, + ) + + def publish_draft_file_as_user(self, user: "User"): + if self.stage.pk == "waiting_for_draft": + return self._publish_draft_file_as_agency(user=user) + if self.stage.pk == "waiting_for_draft_accept_by_supplier": + return self._publish_draft_file_as_supplier(user=user) + elif self.stage.pk == "waiting_for_draft_accept_by_client": + return self._publish_draft_file_as_client(user=user) + raise NotImplementedError + + @transition( + field="stage", source="waiting_for_draft", target="waiting_for_draft_accept_by_supplier", + ) + def _publish_draft_file_as_agency(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_draft") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Draft published", + message_type=MessageType.SUCCESS, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_supplier, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + + @transition( + field="stage", source="waiting_for_draft_accept_by_supplier", target="waiting_for_draft", + ) + def _publish_draft_file_as_supplier(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_draft_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Draft rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + @transition( + field="stage", source="waiting_for_draft_accept_by_client", target="waiting_for_draft", + ) + def _publish_draft_file_as_client(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_draft_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Draft rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + def accept_draft_as_user(self, *, user: "User") -> str: + product_users = [member.user for member in self.project.members.all()] + if self.stage.id == "waiting_for_draft_accept_by_supplier": + result = self._accept_draft_as_supplier(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}/draft", + ) + return result + elif self.stage.id == "waiting_for_draft_accept_by_client": + result = self._accept_draft_as_client(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}/draft", + ) + return result + raise NotImplementedError() + + @transition( + field="stage", + source="waiting_for_draft_accept_by_supplier", + target=RETURN_VALUE("waiting_for_draft_accept_by_client"), + ) + def _accept_draft_as_supplier(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_draft_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Draft accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + return "waiting_for_draft_accept_by_client" + + @transition( + field="stage", source="waiting_for_draft_accept_by_client", target=RETURN_VALUE("waiting_for_ff"), + ) + def _accept_draft_as_client(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_draft_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="Draft accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + FirstFinal.objects.create(product=self) + return "waiting_for_ff" + + ########################## + def publish_ff_file_as_user(self, user: "User"): + if self.stage.pk == "waiting_for_ff": + return self._publish_ff_file_as_agency(user=user) + if self.stage.pk == "waiting_for_ff_accept_by_supplier": + return self._publish_ff_file_as_supplier(user=user) + elif self.stage.pk == "waiting_for_ff_accept_by_client": + return self._publish_ff_file_as_client(user=user) + raise NotImplementedError + + @transition( + field="stage", source="waiting_for_ff", target="waiting_for_ff_accept_by_supplier", + ) + def _publish_ff_file_as_agency(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_ff") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="FF published", + message_type=MessageType.SUCCESS, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_supplier, + action_url="https://outbo.pl/projects/{id}/products/{pid}/ff", + ) + + @transition( + field="stage", source="waiting_for_ff_accept_by_supplier", target="waiting_for_ff", + ) + def _publish_ff_file_as_supplier(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_ff_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="FF rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + @transition( + field="stage", source="waiting_for_ff_accept_by_client", target="waiting_for_ff", + ) + def _publish_ff_file_as_client(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_ff_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="FF rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + def accept_ff_as_user(self, *, user: "User") -> str: + product_users = [member.user for member in self.project.members.all()] + if self.stage.id == "waiting_for_ff_accept_by_supplier": + product_ids = [member.user.id for member in self.project.members.all()] + traders = User.objects.filter(is_trader=True, category=self.project.category).exclude( + id__in=product_ids + ) + result = self._accept_ff_as_supplier(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}/ff", + ) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in traders and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + return result + elif self.stage.id == "waiting_for_ff_accept_by_client": + result = self._accept_ff_as_client(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and (u.is_agency and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}/ff", + ) + return result + raise NotImplementedError() + + @transition( + field="stage", + source="waiting_for_ff_accept_by_supplier", + target=RETURN_VALUE("waiting_for_ff_accept_by_client"), + ) + def _accept_ff_as_supplier(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_ff_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="FF accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + return "waiting_for_ff_accept_by_client" + + @transition( + field="stage", source="waiting_for_ff_accept_by_client", target=RETURN_VALUE("waiting_for_pf"), + ) + def _accept_ff_as_client(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_ff_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="FF accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + PrintingFiles.objects.create(product=self) + return "waiting_for_pf" + + ########### + def publish_pf_file_as_user(self, user: "User"): + if self.stage.pk == "waiting_for_pf": + return self._publish_pf_file_as_agency(user=user) + if self.stage.pk == "waiting_for_pf_accept_by_supplier": + return self._publish_pf_file_as_supplier(user=user) + elif self.stage.pk == "waiting_for_pf_accept_by_client": + return self._publish_pf_file_as_client(user=user) + raise NotImplementedError + + @transition( + field="stage", source="waiting_for_pf", target="waiting_for_pf_accept_by_supplier", + ) + def _publish_pf_file_as_agency(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_pf") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="PF published", + message_type=MessageType.SUCCESS, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_supplier, + action_url="https://outbo.pl/projects/{id}/products/{pid}/pf", + ) + + @transition( + field="stage", source="waiting_for_pf_accept_by_supplier", target="waiting_for_pf", + ) + def _publish_pf_file_as_supplier(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_pf_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="PF rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + @transition( + field="stage", source="waiting_for_pf_accept_by_client", target="waiting_for_pf", + ) + def _publish_pf_file_as_client(self, *, user: "User") -> None: + stage = ProductStage.objects.get(id="waiting_for_pf_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="PF rejected", + message_type=MessageType.FAILURE, + stage=stage, + ) + product_users = [member.user for member in self.project.members.all()] + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + reject=True, + ) + + def accept_pf_as_user(self, *, user: "User") -> str: + product_users = [member.user for member in self.project.members.all()] + if self.stage.id == "waiting_for_pf_accept_by_supplier": + result = self._accept_pf_as_supplier(user=user) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users and (u.is_client and u.is_observer is False), + action_url="https://outbo.pl/projects/{id}/products/{pid}/pf", + ) + return result + elif self.stage.id == "waiting_for_pf_accept_by_client": + result = self._accept_pf_as_client(user=user) + return result + raise NotImplementedError() + + @transition( + field="stage", + source="waiting_for_pf_accept_by_supplier", + target=RETURN_VALUE("waiting_for_pf_accept_by_client"), + ) + def _accept_pf_as_supplier(self, *, user: "User") -> str: + stage = ProductStage.objects.get(id="waiting_for_pf_accept_by_supplier") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="PF accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + return "waiting_for_pf_accept_by_client" + + @transition( + field="stage", source="waiting_for_pf_accept_by_client", target=RETURN_VALUE("completed"), + ) + def _accept_pf_as_client(self, *, user: "User") -> str: + product_users = [member.user for member in self.project.members.all()] + stage = ProductStage.objects.get(id="waiting_for_pf_accept_by_client") + ProductLogItem.objects.create( + product=self, + created_by=user, + message="PF accepted", + message_type=MessageType.SUCCESS, + stage=stage, + ) + self.send_action_emails_for_users_passing_test( + test=lambda u: u in product_users, action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + return "completed" + + def send_action_email_to(self, *, user: User, action_url: str, reject: bool): + if reject is False: + stage = self.stage.next + else: + stage = self.stage.reset_stage + product_status = f"{stage.label} - {stage.description}" + msg_html = render_to_string( + "email/user_notification.html", + { + "project_slug": self.project.slug, + "project_description": self.project.description, + "product_model": self.brief.model_no, + "product_name_pol": self.brief.name_pol, + "product_status": product_status, + "product_current_action": f"{stage.next_action}", + "action_url": action_url.format(id=self.project_id, pid=self.id), + }, + ) + msg_txt = strip_tags(msg_html) + user.email_user( + subject=f"{self.project.slug} / {product_status} / {self.brief.name_pol}", + message=msg_txt, + html_message=msg_html, + ) + + def send_action_emails_for_users_passing_test(self, *, test, action_url, reject: bool = False): + users = [*User.objects.all()] + client_users = filter(test, users) + for u in client_users: + self.send_action_email_to(user=u, action_url=action_url, reject=reject) + + +@receiver(post_save, sender=Product) +def create_brief(sender, instance: Product, created, **kwargs): + if created: + Brief.objects.get_or_create(product=instance) + + +@receiver(post_save, sender=Brief) +def create_brief_config(sender, instance: Brief, created, **kwargs): + if created: + BriefConfig.objects.get_or_create(brief=instance) diff --git a/evg-outbo-api/outbo/apps/products/models/product_stage.py b/evg-outbo-api/outbo/apps/products/models/product_stage.py new file mode 100755 index 0000000..5787c86 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/models/product_stage.py @@ -0,0 +1,41 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.core.models import TimestampableDeletableMixin + + +class ProductStage(TimestampableDeletableMixin, RulesModel): + class Meta: + ordering = ["label"] + + id = models.CharField(verbose_name=_("ID"), primary_key=True, max_length=50) + label = models.CharField(verbose_name=_("label"), max_length=2) + group = models.PositiveSmallIntegerField(verbose_name=_("group"), null=False) + description = models.CharField(verbose_name=_("description"), max_length=254) + next_action = models.CharField(verbose_name=_("next action"), max_length=254) + order = models.PositiveSmallIntegerField(verbose_name=_("order")) + + def __str__(self): + return f"[{self.label}] {self.description}" + + @property + def next(self): + try: + return ProductStage.objects.get(order=self.order + 1) + except Exception: + return ProductStage.objects.order_by("-order").first() + + @property + def reset_stage(self): + stage_map = { + "waiting_for_brief_accept_by_agency": "waiting_for_brief", + "waiting_for_brief_accept_by_client": "waiting_for_brief", + "waiting_for_draft_accept_by_supplier": "waiting_for_draft", + "waiting_for_draft_accept_by_client": "waiting_for_draft", + "waiting_for_ff_accept_by_client": "waiting_for_ff", + "waiting_for_ff_accept_by_supplier": "waiting_for_ff", + "waiting_for_pf_accept_by_supplier": "waiting_for_pf", + "waiting_for_pf_accept_by_client": "waiting_for_pf", + } + return ProductStage.objects.get(id=stage_map[self.id]) diff --git a/evg-outbo-api/outbo/apps/products/queries.py b/evg-outbo-api/outbo/apps/products/queries.py new file mode 100755 index 0000000..af784d8 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/queries.py @@ -0,0 +1,16 @@ +from django.db.models import Q + +from outbo.apps.core.rules import is_client, is_agency, is_supplier + + +def read_product_query(user): + assigned_to_user_query = ( + Q(project__member__user=user, project__member__deleted_at__isnull=True) + if user.is_observer is False and user.is_trader is False + else Q() + ) + if is_client.test(user): + return assigned_to_user_query & Q(project__published=True) + elif (is_agency | is_supplier).test(user): + return assigned_to_user_query & Q() + return ~Q() diff --git a/evg-outbo-api/outbo/apps/products/rules.py b/evg-outbo-api/outbo/apps/products/rules.py new file mode 100755 index 0000000..5a6ebc0 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/rules.py @@ -0,0 +1,146 @@ +from typing import TYPE_CHECKING, Union + +from rules import predicate, always_deny, always_allow + +from outbo.apps.projects.rules import can_read_project + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + from outbo.apps.products.models import ( + Product, + Brief, + Draft, + FirstFinal, + PrintingFiles, + ) + + +# Product +@predicate +def _has_readable_project(user: "User", product: "Product"): + return can_read_project.test(user, product.project) + + +# CRUD +can_create_product = always_deny +can_read_product = _has_readable_project +can_update_product = always_deny +can_delete_product = always_deny + +# Shared +@predicate +def _has_readable_product(user: "User", obj: Union["Brief", "Draft"]): + return can_read_product.test(user, obj.product) + + +# Brief +@predicate +def can_publish_brief(user: "User", brief: "Brief"): + return brief.publishable_for_user(user=user) + + +@predicate +def can_edit_brief(user: "User", brief: "Brief"): + return brief.editable_for_user(user=user) + + +@predicate +def can_accept_brief(user: "User", brief: "Brief"): + return brief.acceptable_for_user(user=user) + + +# CRUD +can_create_brief = always_deny +can_read_brief = _has_readable_product +can_update_brief = can_edit_brief | can_accept_brief +can_delete_brief = always_deny + + +@predicate +def _has_editable_brief(user, photo_or_blanking): + return can_edit_brief.test(user, photo_or_blanking.brief) + + +# Brief files: +can_delete_photo = _has_editable_brief +can_delete_blanking = _has_editable_brief + + +# Draft +@predicate +def can_edit_draft(user: "User", draft: "Draft"): + return draft.editable_for_user(user=user) + + +@predicate +def can_accept_draft(user: "User", draft: "Draft"): + return draft.acceptable_for_user(user=user) + + +# CRUD +can_create_draft = always_deny +can_read_draft = _has_readable_product +can_update_draft = can_edit_draft | can_accept_draft +can_delete_draft = always_deny + +# Draft Files + + +# CRUD +can_create_draft_file = always_allow +can_read_draft_file = always_allow +can_update_draft_file = always_deny +can_delete_draft_file = always_deny + +# FF +@predicate +def can_edit_ff(user: "User", ff: "FirstFinal"): + return ff.editable_for_user(user=user) + + +@predicate +def can_accept_ff(user: "User", ff: "FirstFinal"): + return ff.acceptable_for_user(user=user) + + +# CRUD +can_create_ff = always_deny +can_read_ff = _has_readable_product +can_update_ff = can_edit_ff | can_accept_ff +can_delete_ff = always_deny + +# Draft Files + + +# CRUD +can_create_ff_file = always_allow +can_read_ff_file = always_allow +can_update_ff_file = always_deny +can_delete_ff_file = always_deny + + +# PF +@predicate +def can_edit_pf(user: "User", pf: "PrintingFiles"): + return pf.editable_for_user(user=user) + + +@predicate +def can_accept_pf(user: "User", pf: "PrintingFiles"): + return pf.acceptable_for_user(user=user) + + +# CRUD +can_create_pf = always_deny +can_read_pf = _has_readable_product +can_update_pf = can_edit_pf | can_accept_pf +can_delete_pf = always_deny + +# Draft Files + + +# CRUD +can_create_pf_file = always_allow +can_read_pf_file = always_allow +can_update_pf_file = always_deny +can_delete_pf_file = always_deny diff --git a/evg-outbo-api/outbo/apps/products/serializers/__init__.py b/evg-outbo-api/outbo/apps/products/serializers/__init__.py new file mode 100755 index 0000000..1da5af2 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/__init__.py @@ -0,0 +1,13 @@ +from .photo import PhotoSerializer +from .blanking import BlankingSerializer +from .brief_config import BriefConfigSerializer +from .brief import BriefSerializer +from .product import ProductSerializer +from .packaging_type import PackagingTypeSerializer +from .product_stage import ProductStageSerializer +from .design_stage_file import ( + DraftFileSerializer, + FirstFinalFileSerializer, + PrintingFilesFileSerializer, +) +from .design_stage import DraftSerializer, FirstFinalSerializer, PrintingFilesSerializer diff --git a/evg-outbo-api/outbo/apps/products/serializers/blanking.py b/evg-outbo-api/outbo/apps/products/serializers/blanking.py new file mode 100755 index 0000000..a385aa4 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/blanking.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + +from outbo.apps.products.models import Blanking + + +class BlankingSerializer: + class Create(serializers.ModelSerializer): + class Meta: + model = Blanking + fields = ["file"] + + class List(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name="products:blanking-detail") + filename = serializers.ReadOnlyField() + size = serializers.ReadOnlyField() + + class Meta: + model = Blanking + fields = ["id", "filename", "url", "size"] diff --git a/evg-outbo-api/outbo/apps/products/serializers/brief.py b/evg-outbo-api/outbo/apps/products/serializers/brief.py new file mode 100755 index 0000000..ef1a98c --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/brief.py @@ -0,0 +1,267 @@ +from outbo.apps.products.models import Brief, Photo, Blanking +from outbo.apps.products.serializers import ( + PhotoSerializer, + BlankingSerializer, + BriefConfigSerializer, +) +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied + + +class BaseBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Brief + + +class BriefSerializer: + class Retrieve(BaseBriefSerializer): + photos = PhotoSerializer.List(many=True) + blankings = BlankingSerializer.List(many=True) + config = BriefConfigSerializer.Retrieve() + editable = serializers.SerializerMethodField() + acceptable = serializers.SerializerMethodField() + publishable = serializers.SerializerMethodField() + + class Meta(BaseBriefSerializer.Meta): + fields = [ + "config", + "name_pol", + "name_eng", + "model_no", + "batch_no", + "ean", + "packaging_type", + "main_description", + "extra_description", + "photos", + "blankings", + "certificate", + "information_signs", + "capabilities_icons", + "product_icons", + "has_description_eng", + "has_description_pol", + "has_description_por", + "has_description_rus", + "has_description_spa", + "illustration", + "cleaning_method", + "country_of_origin", + "manual", + "material", + "warning", + "arrangement_photo", + "packshot", + "clipping", + "extra_photo", + "editable", + "acceptable", + "publishable", + "created_at", + "updated_at", + ] + + def get_editable(self, brief: Brief): + return brief.editable_for_user(user=self.context["request"].user) + + def get_acceptable(self, brief: Brief): + return brief.acceptable_for_user(user=self.context["request"].user) + + def get_publishable(self, brief: Brief): + return brief.publishable_for_user(user=self.context["request"].user) + + class Publish(BaseBriefSerializer): + publish = serializers.BooleanField() + + class Meta(BaseBriefSerializer.Meta): + fields = ["publish"] + + def validate(self, attrs): + instance: "Brief" = self.instance + if not instance.publishable_for_user(user=self.context["request"].user): + raise PermissionDenied() + return super().validate(attrs) + + def update(self, instance: "Brief", validated_data): + members = [m.user for m in instance.product.project.members.all()] + current_user = self.context["request"].user + + product = instance.product + product.publish_brief_as_user(user=current_user) + product.send_action_emails_for_users_passing_test( + test=lambda u: u in members and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + product.save() + return instance + + class Accept(BaseBriefSerializer): + accept = serializers.BooleanField() + + class Meta(BaseBriefSerializer.Meta): + fields = ["accept"] + + def validate(self, attrs): + instance: "Brief" = self.instance + if not instance.acceptable_for_user(user=self.context["request"].user): + raise PermissionDenied() + return super().validate(attrs) + + def update(self, instance: "Brief", validated_data): + current_user = self.context["request"].user + product = instance.product + if validated_data["accept"] is True: + product.accept_brief_as_user(user=current_user) + elif validated_data["accept"] is False: + product.reject_brief_as_user(user=current_user) + product.save() + return instance + + class Clone(BaseBriefSerializer): + clone_from = serializers.IntegerField() + + class Meta(BaseBriefSerializer.Meta): + fields = ["clone_from"] + + def update(self, instance: "Brief", validated_data): + clone_from = validated_data.pop("clone_from", None) + if clone_from is not None: + clone_from = Brief.objects.get(pk=clone_from) + clonable_fields = [ + "packaging_type", + "main_description", + "extra_description", + "certificate", + "information_signs", + "capabilities_icons", + "product_icons", + "has_description_eng", + "has_description_pol", + "has_description_por", + "has_description_rus", + "has_description_spa", + "illustration", + "cleaning_method", + "country_of_origin", + "manual", + "material", + "warning", + "arrangement_photo", + "packshot", + "clipping", + "extra_photo", + ] + clonable_config_fields = [ + "illustration", + "cleaning_method", + "country_of_origin", + "manual", + "material", + "warning", + "photobrief", + "packshot", + "clipping", + "extra_photo", + ] + for item in clonable_config_fields: + setattr(instance.config, item, getattr(clone_from.config, item)) + for item in clonable_fields: + setattr(instance, item, getattr(clone_from, item)) + instance.config.save() + instance.save() + return instance + + class Update(BaseBriefSerializer): + accept = serializers.BooleanField() + clone_from = serializers.IntegerField() + + publish = serializers.BooleanField() + + config = BriefConfigSerializer.Update() + photos = PhotoSerializer.Create(many=True) + blankings = BlankingSerializer.Create(many=True) + + class Meta(BaseBriefSerializer.Meta): + fields = [ + "clone_from", + "accept", + "publish", + "config", + "name_pol", + "name_eng", + "model_no", + "batch_no", + "ean", + "packaging_type", + "main_description", + "extra_description", + "photos", + "blankings", + "certificate", + "information_signs", + "capabilities_icons", + "product_icons", + "has_description_eng", + "has_description_pol", + "has_description_por", + "has_description_rus", + "has_description_spa", + "illustration", + "cleaning_method", + "country_of_origin", + "manual", + "material", + "warning", + "arrangement_photo", + "packshot", + "clipping", + "extra_photo", + ] + + def update(self, instance: Brief, validated_data): + if validated_data.get("clone_from", None) is not None: + serializer = BriefSerializer.Clone( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird + + if validated_data.get("accept", None) is not None: + serializer = BriefSerializer.Accept( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird + + if validated_data.get("publish", None) is not None: + serializer = BriefSerializer.Publish( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird + + config = validated_data.pop("config", None) + + photos = validated_data.pop("photos", []) + blankings = validated_data.pop("blankings", []) + + for photo in photos: + Photo.objects.create(brief=instance, **photo) + for blanking in blankings: + Blanking.objects.create(brief=instance, **blanking) + + if config: + for (key, value) in config.items(): + setattr(instance.config, key, value) + instance.config.save() + + result = super().update(instance=instance, validated_data=validated_data) + product = result.product + product.save() # bump updated_at + return result diff --git a/evg-outbo-api/outbo/apps/products/serializers/brief_config.py b/evg-outbo-api/outbo/apps/products/serializers/brief_config.py new file mode 100755 index 0000000..f729439 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/brief_config.py @@ -0,0 +1,33 @@ +from rest_framework import serializers + +from outbo.apps.products.models import BriefConfig + + +class _BaseBriefConfigSerializer(serializers.ModelSerializer): + class Meta: + model = BriefConfig + fields = [ + "illustration", + "cleaning_method", + "country_of_origin", + "manual", + "material", + "warning", + "photobrief", + "packshot", + "clipping", + "extra_photo", + ] + + +class BriefConfigSerializer: + class Retrieve(_BaseBriefConfigSerializer): + class Meta(_BaseBriefConfigSerializer.Meta): + fields = _BaseBriefConfigSerializer.Meta.fields + [ + "created_at", + "updated_at", + ] + + class Update(_BaseBriefConfigSerializer): + class Meta(_BaseBriefConfigSerializer.Meta): + pass diff --git a/evg-outbo-api/outbo/apps/products/serializers/design_stage.py b/evg-outbo-api/outbo/apps/products/serializers/design_stage.py new file mode 100755 index 0000000..c3050b4 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/design_stage.py @@ -0,0 +1,159 @@ +from typing import Union + +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied + +from outbo.apps.products.models import Draft, PrintingFiles, FirstFinal +from outbo.apps.products.serializers import ( + DraftFileSerializer, + FirstFinalFileSerializer, + PrintingFilesFileSerializer, +) + + +class DesignStageSerializer(serializers.ModelSerializer): + class Retrieve(serializers.ModelSerializer): + acceptable = serializers.SerializerMethodField() + editable = serializers.SerializerMethodField() + + class Meta: + fields = ["acceptable", "editable", "files", "created_at", "updated_at"] + + def get_acceptable(self, instance: Union[Draft, FirstFinal, PrintingFiles]): + return instance.acceptable_for_user(user=self.context["request"].user) + + def get_editable(self, instance: Union[Draft, FirstFinal, PrintingFiles]): + return instance.editable_for_user(user=self.context["request"].user) + + class Accept(serializers.ModelSerializer): + accept = serializers.BooleanField() + + class Meta: + fields = ["accept"] + + class Update(serializers.ModelSerializer): + accept = serializers.BooleanField() + + class Meta: + fields = ["accept"] + + +class DraftSerializer: + class Retrieve(DesignStageSerializer.Retrieve): + files = DraftFileSerializer.Retrieve(many=True) + + class Meta(DesignStageSerializer.Retrieve.Meta): + model = Draft + + class Accept(DesignStageSerializer.Accept): + class Meta(DesignStageSerializer.Accept.Meta): + model = Draft + + def validate(self, attrs): + instance: "Draft" = self.instance + if not instance.acceptable_for_user(user=self.context["request"].user): + raise PermissionDenied() + return super().validate(attrs) + + def update(self, instance: "Draft", validated_data): + current_user = self.context["request"].user + product = instance.product + if validated_data["accept"] is True: + product.accept_draft_as_user(user=current_user) + product.save() + return instance + + class Update(DesignStageSerializer.Update): + class Meta(DesignStageSerializer.Update.Meta): + model = Draft + + def update(self, instance: Draft, validated_data): + serializer = DraftSerializer.Accept( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird + + +class FirstFinalSerializer: + class Retrieve(DesignStageSerializer.Retrieve): + files = FirstFinalFileSerializer.Retrieve(many=True) + + class Meta(DesignStageSerializer.Retrieve.Meta): + model = FirstFinal + + class Accept(DesignStageSerializer.Accept): + class Meta(DesignStageSerializer.Accept.Meta): + model = FirstFinal + + def validate(self, attrs): + instance: "FirstFinal" = self.instance + if not instance.acceptable_for_user(user=self.context["request"].user): + raise PermissionDenied() + return super().validate(attrs) + + def update(self, instance: "FirstFinal", validated_data): + current_user = self.context["request"].user + product = instance.product + if validated_data["accept"] is True: + product.accept_ff_as_user(user=current_user) + elif validated_data["accept"] is False: + product.reject_ff_as_user(user=current_user) + product.save() + return instance + + class Update(DesignStageSerializer.Update): + class Meta(DesignStageSerializer.Update.Meta): + model = FirstFinal + + def update(self, instance: FirstFinal, validated_data): + serializer = FirstFinalSerializer.Accept( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird + + +class PrintingFilesSerializer: + class Retrieve(DesignStageSerializer.Retrieve): + files = PrintingFilesFileSerializer.Retrieve(many=True) + + class Meta(DesignStageSerializer.Retrieve.Meta): + model = PrintingFiles + + class Accept(DesignStageSerializer.Accept): + class Meta(DesignStageSerializer.Accept.Meta): + model = PrintingFiles + + def validate(self, attrs): + instance: "PrintingFiles" = self.instance + if not instance.acceptable_for_user(user=self.context["request"].user): + raise PermissionDenied() + return super().validate(attrs) + + def update(self, instance: "PrintingFiles", validated_data): + current_user = self.context["request"].user + product = instance.product + if validated_data["accept"] is True: + product.accept_pf_as_user(user=current_user) + elif validated_data["accept"] is False: + product.reject_pf_as_user(user=current_user) + product.save() + return instance + + class Update(DesignStageSerializer.Update): + class Meta(DesignStageSerializer.Update.Meta): + model = PrintingFiles + + def update(self, instance: PrintingFiles, validated_data): + serializer = PrintingFilesSerializer.Accept( + instance=instance, data=validated_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + return serializer.update( + instance=instance, validated_data=validated_data + ) # NOTE: looks weird diff --git a/evg-outbo-api/outbo/apps/products/serializers/design_stage_file.py b/evg-outbo-api/outbo/apps/products/serializers/design_stage_file.py new file mode 100755 index 0000000..494d51f --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/design_stage_file.py @@ -0,0 +1,150 @@ +from typing import TYPE_CHECKING + +from rest_framework import serializers + +from outbo.apps.products.models import DraftFile, FirstFinalFile, PrintingFilesFile + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class DesignStageFileSerializer: + class Retrieve(serializers.ModelSerializer): + class Meta: + fields = [ + "id", + "url", + "filename", + "size", + "version", + "description", + "created_by", + "created_at", + "updated_at", + "opened_by", + ] + + filename = serializers.ReadOnlyField() + size = serializers.ReadOnlyField() + + class Create(serializers.ModelSerializer): + class Meta: + fields = ["file", "description"] + + def save(self, **kwargs): + created_by = self.context["request"].user + kwargs["created_by"] = created_by + return super().save(**kwargs) + + +class BaseDraftFileSerializer(serializers.ModelSerializer): + class Meta: + model = DraftFile + + +class BaseFirstFinalFileSerializer(serializers.ModelSerializer): + class Meta: + model = FirstFinalFile + + +class BasePrintingFilesFileSerializer(serializers.ModelSerializer): + class Meta: + model = PrintingFilesFile + + +class DraftFileSerializer: + class Retrieve(DesignStageFileSerializer.Retrieve): + class Meta( + DesignStageFileSerializer.Retrieve.Meta, BaseDraftFileSerializer.Meta + ): + pass + + url = serializers.HyperlinkedIdentityField( + view_name="products:draftfile-detail" + ) + + class Create(DesignStageFileSerializer.Create): + class Meta(DesignStageFileSerializer.Create.Meta, BaseDraftFileSerializer.Meta): + pass + + def create(self, validated_data): + current_user: "User" = self.context["request"].user + result: DraftFile = super().create(validated_data) + + draft = result.draft + draft.save() + + product = draft.product + product.publish_draft_file_as_user(user=current_user) + + product.save() + product.project.save() + + return result + + +class FirstFinalFileSerializer: + class Retrieve(DesignStageFileSerializer.Retrieve): + class Meta( + DesignStageFileSerializer.Retrieve.Meta, BaseFirstFinalFileSerializer.Meta + ): + pass + + url = serializers.HyperlinkedIdentityField( + view_name="products:firstfinalfile-detail" + ) + + class Create(DesignStageFileSerializer.Create): + class Meta( + DesignStageFileSerializer.Create.Meta, BaseFirstFinalFileSerializer.Meta + ): + pass + + def create(self, validated_data): + current_user = self.context["request"].user + result: DraftFile = super().create(validated_data) + + ff = result.first_final + ff.save() + + product = ff.product + product.publish_ff_file_as_user(user=current_user) + + product.save() + product.project.save() + + return result + + +class PrintingFilesFileSerializer: + class Retrieve(DesignStageFileSerializer.Retrieve): + class Meta( + DesignStageFileSerializer.Retrieve.Meta, + BasePrintingFilesFileSerializer.Meta, + ): + pass + + url = serializers.HyperlinkedIdentityField( + view_name="products:printingfilesfile-detail" + ) + + class Create(DesignStageFileSerializer.Create): + class Meta( + DesignStageFileSerializer.Create.Meta, BasePrintingFilesFileSerializer.Meta + ): + pass + + def create(self, validated_data): + current_user = self.context["request"].user + result: PrintingFilesFile = super().create(validated_data) + + pf = result.printing_files + pf.save() + + product = pf.product + product.publish_pf_file_as_user(user=current_user) + + product.save() + product.project.save() + + return result diff --git a/evg-outbo-api/outbo/apps/products/serializers/packaging_type.py b/evg-outbo-api/outbo/apps/products/serializers/packaging_type.py new file mode 100755 index 0000000..04d5dd6 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/packaging_type.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from outbo.apps.products.models import PackagingType + + +class PackagingTypeSerializer: + class List(serializers.ModelSerializer): + class Meta: + model = PackagingType + fields = ["id", "name"] diff --git a/evg-outbo-api/outbo/apps/products/serializers/photo.py b/evg-outbo-api/outbo/apps/products/serializers/photo.py new file mode 100755 index 0000000..d637542 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/photo.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from outbo.apps.products.models import Photo + + +class PhotoSerializer: + class Create(serializers.ModelSerializer): + class Meta: + model = Photo + fields = ["file"] + + class List(serializers.ModelSerializer): + filename = serializers.ReadOnlyField() + url = serializers.HyperlinkedIdentityField(view_name="products:photo-detail") + + class Meta: + model = Photo + fields = [ + "id", + "url", + "filename", + ] diff --git a/evg-outbo-api/outbo/apps/products/serializers/product.py b/evg-outbo-api/outbo/apps/products/serializers/product.py new file mode 100755 index 0000000..08150fc --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/product.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from outbo.apps.products.models import Product + + +class BaseProductSerializer(serializers.ModelSerializer): + brief = serializers.HyperlinkedRelatedField( + view_name="products:brief-detail", read_only=True + ) + + draft = serializers.HyperlinkedRelatedField( + view_name="products:draft-detail", read_only=True + ) + first_final = serializers.HyperlinkedRelatedField( + view_name="products:firstfinal-detail", read_only=True + ) + printing_files = serializers.HyperlinkedRelatedField( + view_name="products:printingfiles-detail", read_only=True + ) + photo_brief = serializers.HyperlinkedRelatedField( + view_name="photobriefing:brief-detail", read_only=True + ) + display_name = serializers.ReadOnlyField() + + class Meta: + model = Product + fields = [ + "display_name", + "project", + "stage", + "brief", + "draft", + "first_final", + "printing_files", + "photo_brief", + "created_at", + "updated_at", + ] + + +class ProductSerializer: + class Retrieve(BaseProductSerializer): + pass + + class List(BaseProductSerializer): + class Meta(BaseProductSerializer.Meta): + fields = ["id"] + BaseProductSerializer.Meta.fields diff --git a/evg-outbo-api/outbo/apps/products/serializers/product_stage.py b/evg-outbo-api/outbo/apps/products/serializers/product_stage.py new file mode 100755 index 0000000..2c9dc73 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/serializers/product_stage.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from outbo.apps.products.models import ProductStage + + +class ProductStageSerializer: + class List(serializers.ModelSerializer): + class Meta: + model = ProductStage + fields = [ + "id", + "label", + "group", + "description", + "next_action", + ] diff --git a/evg-outbo-api/outbo/apps/products/services.py b/evg-outbo-api/outbo/apps/products/services.py new file mode 100755 index 0000000..a4300a4 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/services.py @@ -0,0 +1,13 @@ +def brief_has_languages(brief): + languages = [] + if brief.has_description_pol: + languages.append("Polski") + if brief.has_description_eng: + languages.append("Angielski") + if brief.has_description_por: + languages.append("Portugalski") + if brief.has_description_rus: + languages.append("Rosyjski") + if brief.has_description_spa: + languages.append("Hiszpański") + return ",".join(languages) diff --git a/evg-outbo-api/outbo/apps/products/templatetags/__init__.py b/evg-outbo-api/outbo/apps/products/templatetags/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/products/templatetags/template_helpers.py b/evg-outbo-api/outbo/apps/products/templatetags/template_helpers.py new file mode 100755 index 0000000..8f97612 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/templatetags/template_helpers.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + + +@register.filter +def html_new_line(text_field: str): + return text_field.replace("\n", "
") diff --git a/evg-outbo-api/outbo/apps/products/urls.py b/evg-outbo-api/outbo/apps/products/urls.py new file mode 100755 index 0000000..93bd68a --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/urls.py @@ -0,0 +1,66 @@ +from rest_framework_extensions.routers import ExtendedSimpleRouter + +from outbo.apps.activity_log.viewsets import ProductLogItemNestedViewSet +from outbo.apps.products.viewsets import ( + ProductViewSet, + PackagingTypeViewSet, + ProductStageViewSet, + PhotoViewSet, + BlankingViewSet, + BriefViewSet, + DraftViewSet, + FirstFinalViewSet, + PrintingFilesViewSet, + DraftFileViewSet, + DraftFileNestedViewSet, +) +from outbo.apps.products.viewsets.first_final_file import ( + FirstFinalFileNestedViewSet, + FirstFinalFileViewSet, +) +from outbo.apps.products.viewsets.printing_files_file import ( + PrintingFilesFileNestedViewSet, + PrintingFilesFileViewSet, +) + +app_name = "products" +router = ExtendedSimpleRouter() +router.register(prefix="products", viewset=ProductViewSet).register( + prefix="messages", + viewset=ProductLogItemNestedViewSet, + basename="product-message-item", + parents_query_lookups=["product_id"], +) + +router.register(prefix="briefs", viewset=BriefViewSet) + +router.register(prefix="drafts", viewset=DraftViewSet).register( + prefix="files", + viewset=DraftFileNestedViewSet, + basename="drafts-file", + parents_query_lookups=["draft_id"], +) +router.register(prefix="draft-files", viewset=DraftFileViewSet) + +router.register(prefix="ffs", viewset=FirstFinalViewSet).register( + prefix="files", + viewset=FirstFinalFileNestedViewSet, + basename="first-finals-file", + parents_query_lookups=["first_final_id"], +) +router.register(prefix="first-final-files", viewset=FirstFinalFileViewSet) + +router.register(prefix="pfs", viewset=PrintingFilesViewSet).register( + prefix="files", + viewset=PrintingFilesFileNestedViewSet, + basename="printing-files-file", + parents_query_lookups=["printing_files_id"], +) +router.register(prefix="printing-files-files", viewset=PrintingFilesFileViewSet) + +router.register(prefix="packaging-types", viewset=PackagingTypeViewSet) +router.register(prefix="stages", viewset=ProductStageViewSet) +router.register(prefix="photos", viewset=PhotoViewSet) +router.register(prefix="blankings", viewset=BlankingViewSet) + +urlpatterns = router.urls diff --git a/evg-outbo-api/outbo/apps/products/views.py b/evg-outbo-api/outbo/apps/products/views.py new file mode 100755 index 0000000..97e601f --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/views.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.views.generic import TemplateView +from django_weasyprint import WeasyTemplateResponseMixin + +from outbo.apps.products.models import Brief +from outbo.apps.products.services import brief_has_languages + + +class ExportBriefToPDFApiView(WeasyTemplateResponseMixin, TemplateView): + template_name = "pdf/brief_pdf.html" + pdf_stylesheets = [f"{settings.BASE_DIR}/outbo/templates/pdf/pdf.css"] + + def get_context_data(self, **kwargs): + context = super().get_context_data() + brief = Brief.objects.get(pk=kwargs["pk"]) + context["brief"] = brief + context["languages"] = brief_has_languages(brief) + return context diff --git a/evg-outbo-api/outbo/apps/products/viewsets/__init__.py b/evg-outbo-api/outbo/apps/products/viewsets/__init__.py new file mode 100755 index 0000000..fd9e8eb --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/__init__.py @@ -0,0 +1,10 @@ +from .blanking import BlankingViewSet +from .brief import BriefViewSet +from .draft import DraftViewSet +from .draft_file import DraftFileViewSet, DraftFileNestedViewSet +from .first_final import FirstFinalViewSet +from .packaging_type import PackagingTypeViewSet +from .photo import PhotoViewSet +from .product import ProductViewSet +from .product_stage import ProductStageViewSet +from .printing_files import PrintingFilesViewSet diff --git a/evg-outbo-api/outbo/apps/products/viewsets/blanking.py b/evg-outbo-api/outbo/apps/products/viewsets/blanking.py new file mode 100755 index 0000000..4a773ea --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/blanking.py @@ -0,0 +1,31 @@ +from django.http import HttpResponseRedirect +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + DestroyModelMixin, + MultipleSerializerViewSet, +) +from outbo.apps.products.models import Blanking +from outbo.apps.products.serializers import BlankingSerializer + + +class BlankingViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + DestroyModelMixin, + MultipleSerializerViewSet, +): + queryset = Blanking.objects.all() + serializer_class_set = BlankingSerializer + retrieve_permission_classes = [AllowAny] + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + blanking: Blanking = self.get_object() + return HttpResponseRedirect(redirect_to=blanking.file.url) diff --git a/evg-outbo-api/outbo/apps/products/viewsets/brief.py b/evg-outbo-api/outbo/apps/products/viewsets/brief.py new file mode 100755 index 0000000..08f52e7 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/brief.py @@ -0,0 +1,15 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + PartialUpdateModelMixin, +) +from outbo.apps.products.models import Brief +from outbo.apps.products.serializers import BriefSerializer + + +class BriefViewSet( + AutoPermissionViewSetMixin, RetrieveModelMixin, PartialUpdateModelMixin, MultipleSerializerViewSet +): + queryset = Brief.objects.all() + serializer_class_set = BriefSerializer diff --git a/evg-outbo-api/outbo/apps/products/viewsets/draft.py b/evg-outbo-api/outbo/apps/products/viewsets/draft.py new file mode 100755 index 0000000..d724072 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/draft.py @@ -0,0 +1,18 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, + RetrieveModelMixin, + PartialUpdateModelMixin, +) +from outbo.apps.products.models import Draft +from outbo.apps.products.serializers import DraftSerializer + + +class DraftViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +): + queryset = Draft.objects.all() + serializer_class_set = DraftSerializer diff --git a/evg-outbo-api/outbo/apps/products/viewsets/draft_file.py b/evg-outbo-api/outbo/apps/products/viewsets/draft_file.py new file mode 100755 index 0000000..0f27faf --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/draft_file.py @@ -0,0 +1,78 @@ +from django.http import HttpResponseRedirect +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, + NestedViewSetMixin, + RetrieveModelMixin, +) +from outbo.apps.products import rules +from outbo.apps.products.models import DraftFile, Draft +from outbo.apps.products.serializers import DraftFileSerializer + + +class OpenFileAsUser: + def __update_opened_by(self): + object = self.get_object() + if object.opened_by is None: + object.opened_by = [self.request.user.id] + else: + if self.request.user.id not in object.opened_by: + object.opened_by.append(self.request.user.id) + object.save() + + @action(methods=['get'], detail=True, url_path="open-file") + def open_file(self, request, pk=None): + if self.request.user is not None: + self.__update_opened_by() + return Response(data="ok", status=HTTP_200_OK) + + +class DraftFileViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + OpenFileAsUser, +): + queryset = DraftFile.objects.all() + serializer_class_set = DraftFileSerializer + retrieve_permission_classes = [AllowAny] + permission_type_map = { + **AutoPermissionViewSetMixin.permission_type_map, + "open_file": "view", + } + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + return HttpResponseRedirect(redirect_to=self.get_object().file.url) + + +class DraftFileNestedViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, +): + queryset = DraftFile.objects.all() + serializer_class_set = DraftFileSerializer + serializer_map = {**MultipleSerializerViewSet.serializer_map, "open_file": "Retrieve"} + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + draft = Draft.objects.get(pk=parents["draft_id"]) + if not rules.can_edit_draft.test(current_user, draft): + raise PermissionDenied() # NOTE: Should it be here? + + return serializer.save(draft=draft) diff --git a/evg-outbo-api/outbo/apps/products/viewsets/first_final.py b/evg-outbo-api/outbo/apps/products/viewsets/first_final.py new file mode 100755 index 0000000..2f3a8f0 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/first_final.py @@ -0,0 +1,18 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, + RetrieveModelMixin, + PartialUpdateModelMixin, +) +from outbo.apps.products.models import FirstFinal +from outbo.apps.products.serializers import FirstFinalSerializer + + +class FirstFinalViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +): + queryset = FirstFinal.objects.all() + serializer_class_set = FirstFinalSerializer diff --git a/evg-outbo-api/outbo/apps/products/viewsets/first_final_file.py b/evg-outbo-api/outbo/apps/products/viewsets/first_final_file.py new file mode 100755 index 0000000..7799478 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/first_final_file.py @@ -0,0 +1,58 @@ +from django.http import HttpResponseRedirect +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, + NestedViewSetMixin, + RetrieveModelMixin, +) +from outbo.apps.products import rules +from outbo.apps.products.models import FirstFinal, FirstFinalFile +from outbo.apps.products.serializers import FirstFinalFileSerializer +from outbo.apps.products.viewsets.draft_file import OpenFileAsUser + + +class FirstFinalFileViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + OpenFileAsUser +): + queryset = FirstFinalFile.objects.all() + serializer_class_set = FirstFinalFileSerializer + retrieve_permission_classes = [AllowAny] + permission_type_map = { + **AutoPermissionViewSetMixin.permission_type_map, + "open_file": "view", + } + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + return HttpResponseRedirect(redirect_to=self.get_object().file.url) + + +class FirstFinalFileNestedViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, +): + queryset = FirstFinalFile.objects.all() + serializer_class_set = FirstFinalFileSerializer + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + ff = FirstFinal.objects.get(pk=parents["first_final_id"]) + if not rules.can_edit_ff.test(current_user, ff): + raise PermissionDenied() # NOTE: Should it be here? + + return serializer.save(first_final=ff) diff --git a/evg-outbo-api/outbo/apps/products/viewsets/packaging_type.py b/evg-outbo-api/outbo/apps/products/viewsets/packaging_type.py new file mode 100755 index 0000000..2abfe12 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/packaging_type.py @@ -0,0 +1,14 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + ListModelMixin, + MultipleSerializerViewSet, +) +from outbo.apps.products.models import PackagingType +from outbo.apps.products.serializers import PackagingTypeSerializer + + +class PackagingTypeViewSet( + AutoPermissionViewSetMixin, ListModelMixin, MultipleSerializerViewSet, +): + queryset = PackagingType.objects.all() + serializer_class_set = PackagingTypeSerializer diff --git a/evg-outbo-api/outbo/apps/products/viewsets/photo.py b/evg-outbo-api/outbo/apps/products/viewsets/photo.py new file mode 100755 index 0000000..0f7a8fd --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/photo.py @@ -0,0 +1,31 @@ +from django.http import HttpResponseRedirect +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + DestroyModelMixin, +) +from outbo.apps.products.models import Photo +from outbo.apps.products.serializers import PhotoSerializer + + +class PhotoViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + DestroyModelMixin, + MultipleSerializerViewSet, +): + queryset = Photo.objects.all() + serializer_class_set = PhotoSerializer + retrieve_permission_classes = [AllowAny] + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + photo: Photo = self.get_object() + return HttpResponseRedirect(redirect_to=photo.file.url) diff --git a/evg-outbo-api/outbo/apps/products/viewsets/printing_files.py b/evg-outbo-api/outbo/apps/products/viewsets/printing_files.py new file mode 100755 index 0000000..be83760 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/printing_files.py @@ -0,0 +1,18 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, + RetrieveModelMixin, + PartialUpdateModelMixin, +) +from outbo.apps.products.models import PrintingFiles +from outbo.apps.products.serializers import PrintingFilesSerializer + + +class PrintingFilesViewSet( + AutoPermissionViewSetMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +): + queryset = PrintingFiles.objects.all() + serializer_class_set = PrintingFilesSerializer diff --git a/evg-outbo-api/outbo/apps/products/viewsets/printing_files_file.py b/evg-outbo-api/outbo/apps/products/viewsets/printing_files_file.py new file mode 100755 index 0000000..0338ce4 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/printing_files_file.py @@ -0,0 +1,58 @@ +from django.http import HttpResponseRedirect +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, + NestedViewSetMixin, + RetrieveModelMixin, +) +from outbo.apps.products import rules +from outbo.apps.products.models import PrintingFilesFile, PrintingFiles +from outbo.apps.products.serializers import PrintingFilesFileSerializer +from outbo.apps.products.viewsets.draft_file import OpenFileAsUser + + +class PrintingFilesFileViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + RetrieveModelMixin, + MultipleSerializerViewSet, + OpenFileAsUser +): + queryset = PrintingFilesFile.objects.all() + serializer_class_set = PrintingFilesFileSerializer + retrieve_permission_classes = [AllowAny] + permission_type_map = { + **AutoPermissionViewSetMixin.permission_type_map, + "open_file": "view", + } + + def get_permissions(self): + if self.action == "retrieve": + return [permission() for permission in self.retrieve_permission_classes] + return super().get_permissions() + + def retrieve(self, request, *args, **kwargs): + return HttpResponseRedirect(redirect_to=self.get_object().file.url) + + +class PrintingFilesFileNestedViewSet( + NestedViewSetMixin, + AutoPermissionViewSetMixin, + CreateModelMixin, + MultipleSerializerViewSet, +): + queryset = PrintingFilesFile.objects.all() + serializer_class_set = PrintingFilesFileSerializer + + def perform_create(self, *, serializer): + current_user = self.request.user + parents = self.get_parents_query_dict() + pf = PrintingFiles.objects.get(pk=parents["printing_files_id"]) + if not rules.can_edit_pf.test(current_user, pf): + raise PermissionDenied() # NOTE: Should it be here? + + return serializer.save(printing_files=pf) diff --git a/evg-outbo-api/outbo/apps/products/viewsets/product.py b/evg-outbo-api/outbo/apps/products/viewsets/product.py new file mode 100755 index 0000000..985bb49 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/product.py @@ -0,0 +1,31 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + ListModelMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +) +from outbo.apps.products.filters import ProductFilter +from outbo.apps.products.models import Product +from outbo.apps.products.queries import read_product_query +from outbo.apps.products.serializers import ProductSerializer + + +class ProductViewSet( + AutoPermissionViewSetMixin, + ListModelMixin, + RetrieveModelMixin, + PartialUpdateModelMixin, + MultipleSerializerViewSet, +): + queryset = Product.objects.all() + serializer_class_set = ProductSerializer + filterset_class = ProductFilter + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.prefetch_related("brief", "draft", "first_final", "printing_files") + if self.action == "list": + qs_filter = read_product_query(self.request.user) + qs = qs.filter(qs_filter) + return qs diff --git a/evg-outbo-api/outbo/apps/products/viewsets/product_stage.py b/evg-outbo-api/outbo/apps/products/viewsets/product_stage.py new file mode 100755 index 0000000..3ea9873 --- /dev/null +++ b/evg-outbo-api/outbo/apps/products/viewsets/product_stage.py @@ -0,0 +1,14 @@ +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + ListModelMixin, + MultipleSerializerViewSet, +) +from outbo.apps.products.models import ProductStage +from outbo.apps.products.serializers import ProductStageSerializer + + +class ProductStageViewSet( + AutoPermissionViewSetMixin, ListModelMixin, MultipleSerializerViewSet +): + queryset = ProductStage.objects.all() + serializer_class_set = ProductStageSerializer diff --git a/evg-outbo-api/outbo/apps/projects/__init__.py b/evg-outbo-api/outbo/apps/projects/__init__.py new file mode 100755 index 0000000..a8e54da --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/__init__.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class ProjectsAppConfig(AppConfig): + name = "outbo.apps.projects" + label = "projects" + verbose_name = _("Projects") + + +default_app_config = "outbo.apps.projects.ProjectsAppConfig" diff --git a/evg-outbo-api/outbo/apps/projects/admin.py b/evg-outbo-api/outbo/apps/projects/admin.py new file mode 100755 index 0000000..d7aca67 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/admin.py @@ -0,0 +1,53 @@ +from django import forms +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from outbo.apps.projects.models import Project, Member + + +class ProjectCreationForm(forms.ModelForm): + products_amount = forms.IntegerField( + label=_("Products amount"), widget=forms.NumberInput, min_value=1, max_value=20 + ) + + class Meta: + model = Project + fields = [ + "name", + "description", + "products_amount", + "category", + "brand", + "printing_files_date", + "promo_date", + ] + + +class MemberInline(admin.TabularInline): + model = Member + readonly_fields = ["project"] + extra = 1 + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + add_form = ProjectCreationForm + inlines = [MemberInline] + list_display = ('__str__', 'created_at', 'max_product_stage_description') + + def get_form(self, request, obj=None, **kwargs): + defaults = {} + if obj is None: + defaults["form"] = self.add_form + defaults.update(kwargs) + return super().get_form(request, obj, **defaults) + + def save_model(self, request, obj, form, change): + if not change: + obj.save(products_amount=form.cleaned_data["products_amount"]) + obj.save() + + def get_readonly_fields(self, request, obj=None): + if obj: + return super().get_readonly_fields(request, obj=obj) + ("slug",) + return super().get_readonly_fields(request, obj=obj) diff --git a/evg-outbo-api/outbo/apps/projects/fields.py b/evg-outbo-api/outbo/apps/projects/fields.py new file mode 100755 index 0000000..0e3fe3e --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/fields.py @@ -0,0 +1,14 @@ +from django.db.models.fields import CharField + + +class SlugField(CharField): + def __init__(self, *args, slugify=None, **kwargs): + super().__init__(*args, **kwargs) + self.slugify = slugify + + def pre_save(self, model_instance, add): + if add: + value = self.slugify(model_instance) + setattr(model_instance, self.attname, value) + return value + return super().pre_save(model_instance=model_instance, add=add) diff --git a/evg-outbo-api/outbo/apps/projects/filters.py b/evg-outbo-api/outbo/apps/projects/filters.py new file mode 100755 index 0000000..91e829c --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/filters.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from django.db.models import QuerySet, Q +from django_filters import rest_framework as filters, CharFilter + +from outbo.apps.projects.models import Project + + +class ProjectFilterSet(filters.FilterSet): + query = CharFilter(method='query_projects') + order_by = CharFilter(method='order_projects') + + class Meta: + model = Project + fields = ('query',) + + def query_projects(self, queryset: QuerySet, _name: str, value: str): + return queryset.filter( + Q(slug__icontains=value) + | Q(name__icontains=value) + | Q(description__icontains=value) + | Q(product__brief__name_pol__icontains=value) + | Q(product__brief__name_eng__icontains=value) + ).distinct() + + def order_projects(self, queryset: QuerySet, name: str, value: str): + if value.lower() == "mod": + return queryset.order_by("-updated_at__date", "slug") + if value.lower() == "pf": + return queryset.order_by("printing_files_date", "slug") + return queryset + + diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0001_initial.py b/evg-outbo-api/outbo/apps/projects/migrations/0001_initial.py new file mode 100755 index 0000000..00ee497 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0001_initial.py @@ -0,0 +1,122 @@ +# Generated by Django 2.2.7 on 2019-11-29 08:57 + +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields +import outbo.apps.projects.fields +import rules.contrib.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("organizations", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectNumber", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ("prefix", models.CharField(max_length=4)), + ("value", models.PositiveIntegerField(default=1)), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="Project", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "slug", + outbo.apps.projects.fields.SlugField( + max_length=15, verbose_name="project identifier" + ), + ), + ("name", models.CharField(max_length=254, verbose_name="name")), + ("description", models.TextField(verbose_name="description")), + ( + "printing_files_date", + models.DateField(verbose_name="printing files delivery date"), + ), + ("promo_date", models.DateField(verbose_name="promo date")), + ( + "published", + models.BooleanField(default=False, verbose_name="published"), + ), + ( + "brand", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="organizations.Brand", + verbose_name="brand", + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="organizations.Category", + verbose_name="category", + ), + ), + ], + options={"ordering": ["promo_date"],}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0002_member.py b/evg-outbo-api/outbo/apps/projects/migrations/0002_member.py new file mode 100755 index 0000000..17448d1 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0002_member.py @@ -0,0 +1,101 @@ +# Generated by Django 2.2.8 on 2020-01-14 10:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import outbo.apps.core.fields +import outbo.apps.projects.models.member +import rules.contrib.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Member", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + outbo.apps.core.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="Created at" + ), + ), + ( + "updated_at", + outbo.apps.core.fields.ModificationDateTimeField( + auto_now=True, verbose_name="Updated at" + ), + ), + ( + "deleted_at", + outbo.apps.core.fields.RemovalDateTimeField( + blank=True, editable=False, null=True, verbose_name="Deleted at" + ), + ), + ( + "role", + models.CharField( + choices=[ + ( + outbo.apps.projects.models.member.MembershipType( + "agency" + ), + "agency", + ), + ( + outbo.apps.projects.models.member.MembershipType( + "client" + ), + "client", + ), + ( + outbo.apps.projects.models.member.MembershipType( + "supplier" + ), + "supplier", + ), + ], + default=outbo.apps.projects.models.member.MembershipType( + "supplier" + ), + max_length=25, + verbose_name="membership type", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="memberships", + related_query_name="membership", + to="projects.Project", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="memberships", + related_query_name="membership", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False,}, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0003_auto_20200114_1400.py b/evg-outbo-api/outbo/apps/projects/migrations/0003_auto_20200114_1400.py new file mode 100755 index 0000000..51c2695 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0003_auto_20200114_1400.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.8 on 2020-01-14 13:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0002_member"), + ] + + operations = [ + migrations.AlterField( + model_name="member", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="members", + related_query_name="member", + to="projects.Project", + ), + ), + migrations.AlterField( + model_name="member", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="member", + related_query_name="member", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0004_auto_20200211_1509.py b/evg-outbo-api/outbo/apps/projects/migrations/0004_auto_20200211_1509.py new file mode 100755 index 0000000..b746f0a --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0004_auto_20200211_1509.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.8 on 2020-02-11 14:09 + +from django.db import migrations +import outbo.apps.core.fields +import outbo.apps.projects.models.member + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0003_auto_20200114_1400"), + ] + + operations = [ + migrations.AlterField( + model_name="member", + name="role", + field=outbo.apps.core.fields.EnumCharField( + default=outbo.apps.projects.models.member.MembershipType("supplier"), + enum=outbo.apps.projects.models.member.MembershipType, + max_length=25, + verbose_name="membership type", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0005_auto_20200211_1705.py b/evg-outbo-api/outbo/apps/projects/migrations/0005_auto_20200211_1705.py new file mode 100755 index 0000000..804b7d6 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0005_auto_20200211_1705.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.10 on 2020-02-11 16:05 + +from django.db import migrations +import outbo.apps.core.fields +import outbo.apps.projects.models.member + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0004_auto_20200211_1509"), + ] + + operations = [ + migrations.AlterField( + model_name="member", + name="role", + field=outbo.apps.core.fields.EnumCharField( + default=outbo.apps.projects.models.member.MembershipType("supplier"), + enum=outbo.apps.projects.models.member.MembershipType, + max_length=25, + verbose_name="membership type", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0006_auto_20200525_1025.py b/evg-outbo-api/outbo/apps/projects/migrations/0006_auto_20200525_1025.py new file mode 100755 index 0000000..006ee14 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0006_auto_20200525_1025.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.10 on 2020-05-25 08:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0005_auto_20200211_1705"), + ] + + operations = [ + migrations.AlterField( + model_name="member", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + related_query_name="member", + to="projects.Project", + ), + ), + migrations.AlterField( + model_name="member", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="member", + related_query_name="member", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="project", + name="brand", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="organizations.Brand", + verbose_name="brand", + ), + ), + migrations.AlterField( + model_name="project", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="organizations.Category", + verbose_name="category", + ), + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0007_auto_20201007_0948.py b/evg-outbo-api/outbo/apps/projects/migrations/0007_auto_20201007_0948.py new file mode 100755 index 0000000..923f1fd --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0007_auto_20201007_0948.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.16 on 2020-10-07 07:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_auto_20200525_1025'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['updated_at__date', 'slug']}, + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/0008_auto_20201007_1203.py b/evg-outbo-api/outbo/apps/projects/migrations/0008_auto_20201007_1203.py new file mode 100755 index 0000000..1d68168 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/migrations/0008_auto_20201007_1203.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.16 on 2020-10-07 10:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0007_auto_20201007_0948'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['-updated_at__date', 'slug']}, + ), + ] diff --git a/evg-outbo-api/outbo/apps/projects/migrations/__init__.py b/evg-outbo-api/outbo/apps/projects/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/outbo/apps/projects/models/__init__.py b/evg-outbo-api/outbo/apps/projects/models/__init__.py new file mode 100755 index 0000000..6bc00ff --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/models/__init__.py @@ -0,0 +1,3 @@ +from .project_number import ProjectNumber +from .project import Project +from .member import Member diff --git a/evg-outbo-api/outbo/apps/projects/models/member.py b/evg-outbo-api/outbo/apps/projects/models/member.py new file mode 100755 index 0000000..9dfc589 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/models/member.py @@ -0,0 +1,47 @@ +from django.utils.translation import gettext_lazy as _ +from enum import Enum + +from django.db import models +from rules.contrib.models import RulesModel + +from outbo.apps.core.fields import EnumCharField +from outbo.apps.core.models import TimestampableDeletableMixin +from outbo.apps.projects import rules +from outbo.apps.projects.models import Project + + +class MembershipType(Enum): + AGENCY = "agency" + CLIENT = "client" + SUPPLIER = "supplier" + + +class Member(TimestampableDeletableMixin, RulesModel): + class Meta: + rules_permissions = { + "view": rules.can_read_project_member, + "add": rules.can_create_project_member, + "change": rules.can_update_project_member, + "delete": rules.can_delete_project_member, + } + + user = models.ForeignKey( + to="authentication.User", + on_delete=models.CASCADE, + related_name="member", + related_query_name="member", + ) + project = models.ForeignKey( + to=Project, + on_delete=models.CASCADE, + related_name="members", + related_query_name="member", + ) + role = EnumCharField( + verbose_name=_("membership type"), + max_length=25, + blank=False, + null=False, + default=MembershipType.SUPPLIER, + enum=MembershipType, + ) diff --git a/evg-outbo-api/outbo/apps/projects/models/project.py b/evg-outbo-api/outbo/apps/projects/models/project.py new file mode 100755 index 0000000..e7e8888 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/models/project.py @@ -0,0 +1,146 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rules.contrib.models import RulesModel + +from outbo.apps.activity_log.enums import MessageType +from outbo.apps.activity_log.models import ProjectLogItem +from outbo.apps.core.models import TimestampableDeletableMixin, SoftDeletableManager +from outbo.apps.products.models import Product +from outbo.apps.projects import rules +from outbo.apps.projects.fields import SlugField +from outbo.apps.projects.models import ProjectNumber + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + + +class ProjectManager(SoftDeletableManager): + def create(self, **kwargs): + products_amount = kwargs.pop("products_amount", None) + + project: "Project" = super().create(**kwargs) + if products_amount is not None: + project.add_products(products_amount=products_amount) + project.save() + return project + + +class Project(TimestampableDeletableMixin, RulesModel): + objects = ProjectManager() + all_objects = ProjectManager(alive_only=False) + + class Meta: + rules_permissions = { + "view": rules.can_read_project, + "add": rules.can_create_project, + "change": rules.can_update_project, + "delete": rules.can_delete_project, + } + ordering = ["-updated_at__date", "slug"] + + def slugify(self): + brand_prefix = self.category.name[:2].upper() + category_prefix = self.brand.name[:2].upper() + + number = ProjectNumber.objects.get_next_number( + prefix=f"{brand_prefix}{category_prefix}" + ) + week_year = self.promo_date.strftime("%U%y") + return f"#{brand_prefix}{category_prefix}{number}-{week_year}" + + slug = SlugField( + verbose_name=_("project identifier"), max_length=15, slugify=slugify + ) + + name = models.CharField( + verbose_name=_("name"), max_length=254, blank=False, null=False + ) + description = models.TextField( + verbose_name=_("description"), blank=False, null=False + ) + category = models.ForeignKey( + verbose_name=_("category"), + to="organizations.Category", + on_delete=models.CASCADE, + null=False, + ) + brand = models.ForeignKey( + verbose_name=_("brand"), + to="organizations.Brand", + on_delete=models.CASCADE, + null=False, + ) + printing_files_date = models.DateField( + verbose_name=_("printing files delivery date"), null=False + ) + promo_date = models.DateField(verbose_name=_("promo date"), null=False) + + # internal fields + + published = models.BooleanField( + verbose_name=_("published"), blank=False, null=False, default=False + ) + + @property + def _max_product_stage_obj(self): + max_product_stage_obj = ( + self.products.order_by("-stage__order").first() + ) + if not max_product_stage_obj: + return None + return max_product_stage_obj + + @property + def max_product_stage(self): + return self._max_product_stage_obj.stage.id + + @property + def max_product_stage_description(self): + return self._max_product_stage_obj.stage.description + + def __str__(self): + if self.slug: + return f"[{self.slug}] {self.name}" + else: + return self.name + + def publishable_for_user(self, *, user: "User"): + if user.is_observer or user.is_trader: + return False + return not self.published and all( + [ + product.has_brief_publishable_for_user(user=user) + for product in self.products.all() + ] + ) + + def save(self, *args, **kwargs): + products_amount = kwargs.pop("products_amount", None) + super().save(*args, **kwargs) + if products_amount is not None: + self.add_products(products_amount=products_amount) + + def add_products(self, *, products_amount): + [Product.objects.create(project=self) for _ in range(products_amount)] + self.save() + + def publish_as_user(self, *, user: "User"): + self.published = True + for product in self.products.all(): + product.send_action_emails_for_users_passing_test( + test=lambda u: u in [m.user for m in self.members.all()] + and u.is_agency, + action_url="https://outbo.pl/projects/{id}/products/{pid}", + ) + product.publish_brief_as_user(user=user) + product.save() + + ProjectLogItem.objects.create( + project=self, + created_by=user, + message="Project published", + message_type=MessageType.SUCCESS, + ) + self.save() diff --git a/evg-outbo-api/outbo/apps/projects/models/project_number.py b/evg-outbo-api/outbo/apps/projects/models/project_number.py new file mode 100755 index 0000000..e2db8f5 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/models/project_number.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext_lazy as _ + +from django.db import models +from django.db.models import F + +from rules.contrib.models import RulesModel +from outbo.apps.core.models import TimestampableDeletableMixin, SoftDeletableManager + + +class ProjectNumberManager(SoftDeletableManager): + def get_next_number(self, prefix): + current = self.get_or_create(prefix=prefix)[0] + value = current.value + + current.value = F("value") + 1 + current.save() + + return value + + +class ProjectNumber(TimestampableDeletableMixin, RulesModel): + objects = ProjectNumberManager() + all_objects = ProjectNumberManager(alive_only=False) + + prefix = models.CharField(max_length=4) + value = models.PositiveIntegerField(default=1) diff --git a/evg-outbo-api/outbo/apps/projects/queries.py b/evg-outbo-api/outbo/apps/projects/queries.py new file mode 100755 index 0000000..45ff127 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/queries.py @@ -0,0 +1,12 @@ +from django.db.models import Q + +from outbo.apps.core.rules import is_agency, is_supplier, is_client + + +def read_project_query(user): + assigned_to_user_query = Q(member__user=user, member__deleted_at__isnull=True) + if is_client.test(user): + return assigned_to_user_query & Q(published=True) + elif (is_agency | is_supplier).test(user): + return assigned_to_user_query & Q() + return ~Q() diff --git a/evg-outbo-api/outbo/apps/projects/rules.py b/evg-outbo-api/outbo/apps/projects/rules.py new file mode 100755 index 0000000..3abfbe3 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/rules.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from rules import predicate, always_deny + +from outbo.apps.core.rules import is_supplier, is_agency, is_client + +if TYPE_CHECKING: + from outbo.apps.projects.models import Project, Member + + +@predicate +def _project_published(_, project: "Project"): + return project.published + + +can_publish_project = is_supplier & ~_project_published + +can_create_project = is_supplier +can_read_project = is_supplier | is_agency | (is_client & _project_published) + +can_update_project = can_publish_project +can_delete_project = always_deny + + +@predicate +def _can_remove_this_member(user: "User", member: "Member"): + project = member.project + all_memberships = project.members + current_user_membership = all_memberships.filter(user=user).first() + if current_user_membership is None: + return False + current_role_memberships = all_memberships.filter(role=current_user_membership.role) + return ( + member.role == current_user_membership.role + and current_role_memberships.count() > 1 + ) + + +can_create_project_member = is_supplier +can_read_project_member = always_deny +can_update_project_member = always_deny +can_delete_project_member = _can_remove_this_member diff --git a/evg-outbo-api/outbo/apps/projects/serializers.py b/evg-outbo-api/outbo/apps/projects/serializers.py new file mode 100755 index 0000000..fd48731 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/serializers.py @@ -0,0 +1,186 @@ +from rest_framework import serializers +from rest_framework.reverse import reverse_lazy + +from outbo.apps.projects.models import Project, Member +from outbo.apps.projects.models.member import MembershipType + + +class _BaseProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ["name"] + + +class BaseMemberSerializer(serializers.ModelSerializer): + class Meta: + model = Member + + +class MemberSerializer: + class Create(BaseMemberSerializer): + class Meta(BaseMemberSerializer.Meta): + fields = ["user"] + + class AfterCreate(BaseMemberSerializer): + role = serializers.SerializerMethodField() + + class Meta(BaseMemberSerializer.Meta): + fields = ["id", "user", "role"] + + def get_role(self, instance): + t, val = str(instance.role).split(".", 1) + return MembershipType[val].value + + class List(BaseMemberSerializer): + role = serializers.SerializerMethodField() + + class Meta(BaseMemberSerializer.Meta): + fields = ["id", "user", "role"] + + def get_role(self, instance): + t, val = str(instance.role).split(".", 1) + return MembershipType[val].value + + +class ProjectSerializer: + class Retrieve(_BaseProjectSerializer): + products = serializers.SerializerMethodField() + publishable = serializers.SerializerMethodField() + products_amount = serializers.SerializerMethodField() + max_product_stage = serializers.ReadOnlyField() + members = MemberSerializer.List(many=True) + + class Meta(_BaseProjectSerializer.Meta): + fields = [ + "slug", + *_BaseProjectSerializer.Meta.fields, + "description", + "category", + "members", + "brand", + "products_amount", + "products", + "printing_files_date", + "promo_date", + "max_product_stage", + "published", + "publishable", + "created_at", + "updated_at", + ] + + def get_publishable(self, project: "Project"): + current_user = self.context["request"].user + return project.publishable_for_user(user=current_user) + + def get_products(self, project: "Project"): + url = reverse_lazy("products:product-list") + return f"{url}?project={project.pk}" + + def get_products_amount(self, project: "Project"): + return project.products.count() + + class AfterCreate(_BaseProjectSerializer): + products = serializers.SerializerMethodField() + publishable = serializers.SerializerMethodField() + products_amount = serializers.SerializerMethodField() + max_product_stage = serializers.ReadOnlyField() + members = MemberSerializer.List(many=True) + + class Meta(_BaseProjectSerializer.Meta): + fields = [ + "id", + "slug", + *_BaseProjectSerializer.Meta.fields, + "description", + "category", + "brand", + "products_amount", + "products", + "printing_files_date", + "promo_date", + "members", + # Computed + "max_product_stage", + "published", + "publishable", + "created_at", + "updated_at", + ] + + def get_publishable(self, project: "Project"): + current_user = self.context["request"].user + return project.publishable_for_user(user=current_user) + + def get_products(self, project: "Project"): + # TODO: Hyperlink? + url = reverse_lazy("products:product-list") + return f"{url}?project={project.pk}" + + def get_products_amount(self, project: "Project"): + return project.products.count() + + class List(_BaseProjectSerializer): + products = serializers.SerializerMethodField() + max_product_stage = serializers.ReadOnlyField() + members = MemberSerializer.List(many=True) + + class Meta(_BaseProjectSerializer.Meta): + fields = [ + "id", + "slug", + *_BaseProjectSerializer.Meta.fields, + "members", + "products", + "printing_files_date", + "category", + "brand", + # Computed + "max_product_stage", + "published", + "created_at", + "updated_at", + ] + + def get_products(self, instance): + # TODO: refactor, maybe custom serializerfield + url = reverse_lazy("products:product-list") + return f"{url}?project={instance.pk}" + + class Create(_BaseProjectSerializer): + products_amount = serializers.IntegerField(min_value=1, write_only=True) + + class Meta(_BaseProjectSerializer.Meta): + fields = [ + *_BaseProjectSerializer.Meta.fields, + "description", + "category", + "brand", + "products_amount", + "printing_files_date", + "promo_date", + "created_at", + "updated_at", + ] + + # NOTE: business logic? + def create(self, validated_data): + products_amount = validated_data.pop("products_amount") + project = super().create(validated_data) + project.add_products(products_amount=products_amount) + membership = Member.objects.create( + user=self.context["request"].user, + role=MembershipType.SUPPLIER, + project=project, + ) + project.members.add(membership) + return project + + class Update(serializers.Serializer): + publish = serializers.BooleanField(default=False) + + def update(self, instance: Project, validated_data): + current_user = self.context["request"].user + if validated_data["publish"] is True: + instance.publish_as_user(user=current_user) + return instance diff --git a/evg-outbo-api/outbo/apps/projects/urls.py b/evg-outbo-api/outbo/apps/projects/urls.py new file mode 100755 index 0000000..aa4f386 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/urls.py @@ -0,0 +1,23 @@ +from rest_framework_extensions.routers import ExtendedSimpleRouter + +from outbo.apps.activity_log.viewsets import ProjectLogItemNestedViewSet +from outbo.apps.projects.viewsets import ProjectViewSet, ProjectMemberNestedViewSet + +app_name = "projects" +router = ExtendedSimpleRouter() + +_project_routes = router.register(prefix="projects", viewset=ProjectViewSet) +_project_routes.register( + prefix="messages", + viewset=ProjectLogItemNestedViewSet, + basename="project-message-item", + parents_query_lookups=["project_id"], +) +_project_routes.register( + prefix="members", + viewset=ProjectMemberNestedViewSet, + basename="project-member-item", + parents_query_lookups=["project_id"], +) + +urlpatterns = router.urls diff --git a/evg-outbo-api/outbo/apps/projects/viewsets.py b/evg-outbo-api/outbo/apps/projects/viewsets.py new file mode 100755 index 0000000..c183b52 --- /dev/null +++ b/evg-outbo-api/outbo/apps/projects/viewsets.py @@ -0,0 +1,82 @@ +from django.db.models import Q +from django_filters import rest_framework as filters + +from outbo.apps.core.viewsets import ( + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, + CreateModelMixin, + PartialUpdateModelMixin, + RetrieveModelMixin, + ListModelMixin, + DestroyModelMixin, + NestedViewSetMixin, +) +from outbo.apps.projects.filters import ProjectFilterSet +from outbo.apps.projects.models import Project, Member +from outbo.apps.projects.models.member import MembershipType +from outbo.apps.projects.queries import read_project_query +from outbo.apps.projects.serializers import ProjectSerializer, MemberSerializer + + +class ProjectViewSet( + RetrieveModelMixin, + ListModelMixin, + CreateModelMixin, + PartialUpdateModelMixin, + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, +): + queryset = Project.objects.all() + serializer_class_set = ProjectSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = ProjectFilterSet + + def get_queryset(self): + qs = super().get_queryset() + if self.action == "list": + qs_filter = read_project_query(self.request.user) + qs = qs.filter(qs_filter) + # TODO: try to refactor this poor loops + if self.request.user.is_observer or self.request.user.is_trader: + projects_id = [] + for project in Project.objects.filter(published=True): + members = project.members.filter(role=self.request.user.user_role) + for member in members: + if member.user.organization == self.request.user.organization: + projects_id.append(project.id) + qs_id = qs.values_list("id", flat=True) + qs = Project.objects.filter(Q(id__in=projects_id) | Q(id__in=qs_id)) + if self.request.user.is_trader and self.request.user.category is not None: + qs = qs.filter(category=self.request.user.category) + return qs + + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request=request, *args, **kwargs) + + +class ProjectMemberNestedViewSet( + ListModelMixin, + CreateModelMixin, + DestroyModelMixin, + NestedViewSetMixin, + AutoPermissionViewSetMixin, + MultipleSerializerViewSet, +): + queryset = Member.objects.all() + serializer_class_set = MemberSerializer + + def perform_create(self, *, serializer: MemberSerializer.Create): + user = serializer.validated_data["user"] + role = None + if user.is_agency: + role = MembershipType.AGENCY + elif user.is_client: + role = MembershipType.CLIENT + elif user.is_supplier: + role = MembershipType.SUPPLIER + parents = self.get_parents_query_dict() + project = Project.objects.get(pk=parents["project_id"]) + membership = Member.objects.filter(project=project, user=user).first() + if membership: + serializer.instance = membership + return serializer.save(project=project, role=role) diff --git a/evg-outbo-api/outbo/settings.py b/evg-outbo-api/outbo/settings.py new file mode 100755 index 0000000..953f818 --- /dev/null +++ b/evg-outbo-api/outbo/settings.py @@ -0,0 +1,239 @@ +import os + +from configurations import Configuration, values + + +class Base(Configuration): + @classmethod + def pre_setup(cls): + super().pre_setup() + from patches import patches + + patches.on() + + APP_VERSION = "1.0.0" + + DATA_UPLOAD_MAX_MEMORY_SIZE = 26214400000 + + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + SECRET_KEY = values.SecretValue() + DEBUG = values.BooleanValue(True) + ALLOWED_HOSTS = values.ListValue([]) + + INSTALLED_APPS = [ + # internal + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # 3rd party + "django_filters", + "nested_admin", + "polymorphic", + "corsheaders", + "rules", + "django_extensions", + "rest_framework", + "rest_framework.authtoken", + # core apps + "outbo.apps.core", + "outbo.apps.organizations", + "outbo.apps.authentication", + "outbo.apps.projects", + "outbo.apps.products", + "outbo.apps.photobriefing", + "outbo.apps.final_photobriefing", + "outbo.apps.activity_log", + ] + + MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + ] + + AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "rules.permissions.ObjectPermissionBackend", + ] + + ROOT_URLCONF = "outbo.urls" + + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["outbo/templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } + ] + + WSGI_APPLICATION = "outbo.wsgi.application" + + DATABASES = values.DatabaseURLValue() + + AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, + ] + + LANGUAGE_CODE = "pl-PL" + TIME_ZONE = "Europe/Warsaw" + USE_I18N = True + USE_L10N = True + USE_TZ = True + STATIC_URL = "/static/" + MEDIA_URL = "/uploads/" + + AUTH_USER_MODEL = "authentication.User" + + # DRF + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "outbo.apps.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_RENDERER_CLASSES": ( + "djangorestframework_camel_case.render.CamelCaseJSONRenderer", + "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", + ), + "DEFAULT_PARSER_CLASSES": ( + "outbo.apps.core.parsers.CamelCaseNestedMultiPartParser", + "djangorestframework_camel_case.parser.CamelCaseJSONParser", + "djangorestframework_camel_case.parser.CamelCaseFormParser", + ), + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend" + ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "UPLOADED_FILES_USE_URL": False, + } + + CORS_ORIGIN_WHITELIST = values.ListValue([]) + USE_X_FORWARDED_HOST = values.BooleanValue(False) + STATIC_ROOT = values.Value() + MEDIA_ROOT = values.Value() + LOCALE_PATHS = [os.path.join(BASE_DIR, "outbo", "locale")] + + +class Test(Base): + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + LANGUAGE_CODE = "en-us" + TIME_ZONE = "UTC" + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "outbo.apps.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend" + ], + "DEFAULT_PARSER_CLASSES": [ + "outbo.apps.core.parsers.NestedMultiPartParser", + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.FormParser", + ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "UPLOADED_FILES_USE_URL": False, + } + + +class Dev(Base): + DOTENV = "dev.env" + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + MIDDLEWARE = ["silk.middleware.SilkyMiddleware"] + Base.MIDDLEWARE + INSTALLED_APPS = ["silk"] + Base.INSTALLED_APPS + + +class Staging(Base): + DEBUG = False + STATIC_ROOT = "/host/static/" + MEDIA_ROOT = "/host/uploads/" + SENTRY_DSN = values.URLValue(environ_required=True) + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + + @classmethod + def post_setup(cls): + super().post_setup() + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=str(cls.SENTRY_DSN), + integrations=[DjangoIntegration()], + environment="Staging", + ) + + +class Prod(Base): + DEBUG = False + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + DEFAULT_FILE_STORAGE = "outbo.apps.core.storages.S3MediaStorage" + STATICFILES_STORAGE = "outbo.apps.core.storages.S3StaticStorage" + + INSTALLED_APPS = ["anymail"] + Base.INSTALLED_APPS + + AWS_ACCESS_KEY_ID = values.Value(environ_required=True) + AWS_SECRET_ACCESS_KEY = values.Value(environ_required=True) + AWS_S3_REGION_NAME = values.Value(environ_required=True) + AWS_S3_ENDPOINT_URL = values.Value(environ_required=True) + + AWS_STORAGE_BUCKET_STATIC_NAME = values.Value(environ_required=True) + AWS_STORAGE_BUCKET_MEDIA_NAME = values.Value(environ_required=True) + + AWS_S3_FILE_OVERWRITE = values.BooleanValue(False) + + EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" + DEFAULT_FROM_EMAIL = values.Value(environ_required=True) + + MAILGUN_API_KEY = values.Value(environ_required=True) + MAILGUN_SENDER_DOMAIN = values.Value(environ_required=True) + + @property + def MEDIA_URL(self): # noqa + return ( + f"https://{Prod.AWS_S3_ENDPOINT_URL}/{Prod.AWS_STORAGE_BUCKET_MEDIA_NAME}/" + ) + + @property + def ANYMAIL(self): # noqa + return { + "MAILGUN_API_URL": "https://api.eu.mailgun.net/v3", + "MAILGUN_API_KEY": Prod.MAILGUN_API_KEY, + "MAILGUN_SENDER_DOMAIN": Prod.MAILGUN_SENDER_DOMAIN, + } + + SENTRY_DSN = values.URLValue(environ_required=True) + + @classmethod + def post_setup(cls): + super().post_setup() + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=str(cls.SENTRY_DSN), + integrations=[DjangoIntegration()], + environment="Production", + ) diff --git a/evg-outbo-api/outbo/templates/email/user_notification.html b/evg-outbo-api/outbo/templates/email/user_notification.html new file mode 100755 index 0000000..8bf6f16 --- /dev/null +++ b/evg-outbo-api/outbo/templates/email/user_notification.html @@ -0,0 +1,100 @@ + + + + + + Outbo + + + + + + + + +
+ + + + + + + + + + + +
+ Projekt: + +
+
+
{{ project_slug }}
+
+
{{ project_description }}
+
+ + + + + + + + +
+ Produkt: + +
+
+
{{ product_model }} / {{ product_name_pol }}
+
+ + + + + + + + +
+ Status: + +
+
+
{{ product_status }}
+
+ + + + + + + + +
+ Wymagane działanie: + +
+
+
{{ product_current_action }}
+
+ + + + +
+ + Zobacz + +
+
+ + + diff --git a/evg-outbo-api/outbo/templates/nesting/admin/inlines/stacked_single.html b/evg-outbo-api/outbo/templates/nesting/admin/inlines/stacked_single.html new file mode 100755 index 0000000..428825d --- /dev/null +++ b/evg-outbo-api/outbo/templates/nesting/admin/inlines/stacked_single.html @@ -0,0 +1,62 @@ +{% load i18n nested_admin admin_urls %} +{% with inline_admin_formset.formset.is_nested as is_nested %} + +{% with inline_admin_formset.opts as inline_opts %} +
+ + {% ifinlineclasses %}
{% endifinlineclasses %} +

+ {% if inline_admin_formset.opts.title %}{{ inline_admin_formset.opts.title }}{% else %}{{ inline_admin_formset.opts.verbose_name|capfirst }}{% endif %} +

+ + {{ inline_admin_formset.formset.management_form }} + {{ inline_admin_formset.formset.non_form_errors }} + +
+ + {% with inline_admin_formset.opts.sortable_field_name|default:"" as sortable_field_name %} + {% for inline_admin_form in inline_admin_formset|formsetsort:sortable_field_name %} + {% if forloop.first %} +
+ {% endif %} + + {% endfor %} + {% endwith %} +
+ {% ifinlineclasses %}
{% endifinlineclasses %} +
+{% endwith %}{# ends with inline_admin_formset.opts as inline_opts #} + +{% endwith %}{# ends {% with inline_admin_formset.formset.is_nested as is_nested %} #} \ No newline at end of file diff --git a/evg-outbo-api/outbo/templates/nesting/admin/inlines/tabular_single.html b/evg-outbo-api/outbo/templates/nesting/admin/inlines/tabular_single.html new file mode 100755 index 0000000..22d9083 --- /dev/null +++ b/evg-outbo-api/outbo/templates/nesting/admin/inlines/tabular_single.html @@ -0,0 +1,128 @@ +{% load admin_urlname admin_urlquote from admin_urls %} +{% load i18n nested_admin %} +{% with inline_admin_formset.formset.is_nested as is_nested %} + +{% with inline_admin_formset.opts as inline_opts %} +
+ + +
+{% endwith %}{# ends with inline_admin_formset.opts as inline_opts #} + +{% endwith %}{# ends {% with inline_admin_formset.formset.is_nested as is_nested %} #} diff --git a/evg-outbo-api/outbo/templates/pdf/brief_pdf.html b/evg-outbo-api/outbo/templates/pdf/brief_pdf.html new file mode 100755 index 0000000..aa7d568 --- /dev/null +++ b/evg-outbo-api/outbo/templates/pdf/brief_pdf.html @@ -0,0 +1,183 @@ +{% load template_helpers %} + + + + + {{ brief.model_no }}/{{ brief.name_pol }} + + + +
+
+
+ + EVG PACKAGING BRIEF + +
+
+ data wygenerowania dokumentu: + {% now "SHORT_DATETIME_FORMAT" %} +
+
+ +
+
+ projekt: +
+
+
+
+
+ Projekt ID: {{ brief.product.project.slug }} +
+
+ Kategoria: {{ brief.product.project.category }} +
+
+ Marka: {{ brief.product.project.brand }} +
+ +
+
+
+ Printing files: {{brief.product.project.printing_files_date}} +
+
+ Data promocji: {{brief.product.project.promo_date}} + +
+
+
+
+ +
+
+ produkt: +
+
+
+
+
+ Produkt:{{brief.model_no}} / {{brief.name_pol}} +
+
+ Model nr.: + {{ brief.model_no }} +
+
+ Nr. Partii: + {{ brief.batch_no }} +
+
+ Typ opakowania: + {{ brief.packaging_type }} +
+
+ Języki opisów: + {{ languages|html_new_line|safe }} +
+
+ EAN: + {{ brief.ean }} +
+
+
+
+
+
+ opis główny pl: +
+
+
+ {{ brief.main_description|html_new_line|safe }} +
+
+ {{ brief.extra_description|html_new_line|safe }} +
+
+
+
+ dodatkowe elementy opisu: +
+
+ {% if brief.config.material %} +
+ Materiał: + {{ brief.material|html_new_line|safe }} +
+ {% endif %} + {% if brief.config.country_of_origin %} +
+ Kraj produkcji: + {{ brief.country_of_origin|html_new_line|safe }} +
+ {% endif %} + {% if brief.config.warning %} +
+ Ostrzeżenia: + {{ brief.warning|html_new_line|safe }} +
+ {% endif %} + +
+
+
+ oznaczenia: +
+
+
+ certyfikaty: + {{ brief.certificate|html_new_line|safe }} +
+
+ znaki informacyjne: + {{ brief.information_signs|html_new_line|safe }} +
+
+ ikony kompetencji: + {{ brief.capabilities_icons|html_new_line|safe }} +
+
+ ikony produktowe: + {{ brief.product_icons|html_new_line| safe}} +
+
+ {% if brief.config.photobrief %} +
+
+ foto brief: +
+
+
+ Zdjęcia aranżacyjne: + {{ brief.arrangement_photo|html_new_line|safe }} +
+
+ Dodatkowe zdjęcia: +
+ {% if brief.config.extra_photo %} +
+ Zdjęcia dodatkowe: + {{brief.extra_photo|html_new_line|safe }} +
+ {% endif %} + {% if brief.config.clipping %} +
+ Clippingi: + {{ brief.clipping|html_new_line|safe }} +
+ {% endif %} + {% if brief.config.packshot %} +
+ Packshot: + {{ brief.packshot|html_new_line|safe }} +
+ {% endif %} +
+ {% endif %} +
+ [END] +
+
+ + \ No newline at end of file diff --git a/evg-outbo-api/outbo/templates/pdf/pdf.css b/evg-outbo-api/outbo/templates/pdf/pdf.css new file mode 100755 index 0000000..8c4c11d --- /dev/null +++ b/evg-outbo-api/outbo/templates/pdf/pdf.css @@ -0,0 +1,109 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + +body { + font-family: 'Roboto', sans-serif; +} + +.field { +} + +.field-description { + color: rgb(123, 123, 123); + font-size: 12px; + margin-top: 5px; +} + +.field-value { + color: #000; + font-size: 12px; + margin-left: 2px; + margin-top: 5px; + line-height: 25px; +} + +.header { + width: 100vw; + text-align: center; +} +.header .section-text{ + font-weight: bold; +} +.small-text { + font-size: 10px; +} + +.color-red { + color: rgb(234, 119, 118); +} + +.color-gray { + color: rgb(123, 123, 123) !important; +} + +.section-text { + text-transform: uppercase; + font-weight: 600; + font-size: 16px; + line-height: 25px; +} + +.sub-section { + margin-top: 20px; + display: flex; + flex-direction: column; +} + +.sub-section-text { + font-size: 14px; + text-transform: uppercase; + font-weight: normal; +} + +.section-header { + display: flex; + align-items: center; + margin-top: 20px; +} + + +.column:first-child { + margin-top: 10px; +} + +.horizontal-line { + flex: 1; + display: flex; + justify-content: center; + align-self: center; + margin: 0 0.5rem; +} + +.border-red { + border-bottom: 1px solid rgb(234, 119, 118); +} + +.border-gray { + border-bottom: 1px solid rgb(123, 123, 123); +} + +.row { + display: flex; + flex-direction: row; +} + +.spaced { + justify-content: space-between +} + +.column { + display: flex; + flex-direction: column +} + +.mt-10 { + margin-top: 10px; +} + +.mt-30 { + margin-top: 30px; +} \ No newline at end of file diff --git a/evg-outbo-api/outbo/urls.py b/evg-outbo-api/outbo/urls.py new file mode 100755 index 0000000..a120fd0 --- /dev/null +++ b/evg-outbo-api/outbo/urls.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, re_path, include + +urlpatterns = [ + path("admin/", admin.site.urls), + path("nested_admin/", include("nested_admin.urls")), + path("", include("outbo.apps.core.urls")), + path("", include("outbo.apps.authentication.urls", namespace="authentication")), + path("", include("outbo.apps.organizations.urls", namespace="organizations")), + path("", include("outbo.apps.projects.urls", namespace="projects")), + path("", include("outbo.apps.products.urls", namespace="products")), + path("", include("outbo.apps.photobriefing.urls", namespace="photobriefing")), + path( + "", + include("outbo.apps.final_photobriefing.urls", namespace="final_photobriefing"), + ), + path("", include("outbo.apps.activity_log.urls", namespace="activity_log")), +] + +if settings.DEBUG: + urlpatterns += [re_path(r"^silk/", include("silk.urls", namespace="silk"))] + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/evg-outbo-api/outbo/wsgi.py b/evg-outbo-api/outbo/wsgi.py new file mode 100755 index 0000000..07884b4 --- /dev/null +++ b/evg-outbo-api/outbo/wsgi.py @@ -0,0 +1,10 @@ +import os + +from configurations.wsgi import get_wsgi_application + +from patches import patches + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "outbo.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Dev") + +application = get_wsgi_application() diff --git a/evg-outbo-api/patches.py b/evg-outbo-api/patches.py new file mode 100755 index 0000000..f7fc067 --- /dev/null +++ b/evg-outbo-api/patches.py @@ -0,0 +1,91 @@ +from deepmerge import Merger, STRATEGY_END +from django.db.models.signals import class_prepared +from django.utils.functional import curry +from django_fsm import ( + FSMFieldMixin, + get_all_FIELD_transitions, + get_available_FIELD_transitions, + get_available_user_FIELD_transitions, +) +from rest_framework.request import _hasattr, is_form_media_type, Request + +__all__ = ["patches"] + + +def merge_dicts(config, path, base, nxt): + if isinstance(base, list) and isinstance(nxt, list) and len(base) == len(nxt): + data = [{**a, **b} for a, b in zip(base, nxt)] + return data + return STRATEGY_END + + +merger = Merger( + [(list, merge_dicts), (list, "append"), (dict, "merge")], ["override"], ["override"] +) + + +def _patched_load_data_and_files(self): + """ + Parses the request content into `self.data`. + """ + if not _hasattr(self, "_data"): + self._data, self._files = self._parse() + if self._files: + self._full_data = merger.merge(self._data, self._files) + else: + self._full_data = self._data + + # if a form media type, copy data & files refs to the underlying + # http request so that closable objects are handled appropriately. + if is_form_media_type(self.content_type): + self._request._post = self.POST + self._request._files = self.FILES + + +def _modified_contribute_to_class(self, cls, name, **kwargs): + self.base_cls = cls + + super(FSMFieldMixin, self).contribute_to_class(cls, name, **kwargs) + setattr(cls, f"{self.name}_description", self.descriptor_class(self)) + setattr( + cls, + "get_all_{0}_transitions".format(self.name), + curry(get_all_FIELD_transitions, field=self), + ) + setattr( + cls, + "get_available_{0}_transitions".format(self.name), + curry(get_available_FIELD_transitions, field=self), + ) + setattr( + cls, + "get_available_user_{0}_transitions".format(self.name), + curry(get_available_user_FIELD_transitions, field=self), + ) + class_prepared.connect(self._collect_transitions) + + +# Do not obscure related model in FSMKeyField +# Pull request https://github.com/viewflow/django-fsm/pull/200/files#diff-7b4b9604f1842ff54833d14e644e9710 + + +class Patches: + def __init__(self): + self._load_data_and_files = None + self._contribute_to_class = None + + def on(self): + self._load_data_and_files = Request._load_data_and_files + self._contribute_to_class = FSMFieldMixin.contribute_to_class + + Request._load_data_and_files = _patched_load_data_and_files + FSMFieldMixin.contribute_to_class = _modified_contribute_to_class + + def off(self): + if self._load_data_and_files: + Request._load_data_and_files = self._load_data_and_files + if self._contribute_to_class: + FSMFieldMixin.contribute_to_class = self._contribute_to_class + + +patches = Patches() diff --git a/evg-outbo-api/poetry.lock b/evg-outbo-api/poetry.lock new file mode 100755 index 0000000..da23264 --- /dev/null +++ b/evg-outbo-api/poetry.lock @@ -0,0 +1,1656 @@ +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "argh" +version = "0.26.2" +description = "An unobtrusive argparse wrapper with natural syntax" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "20.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +name = "autopep8" +version = "1.5.4" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pycodestyle = ">=2.6.0" +toml = "*" + +[[package]] +name = "black" +version = "19.10b0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +pathspec = ">=0.6,<1" +regex = "*" +toml = ">=0.9.4" +typed-ast = ">=1.4.0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +name = "boto3" +version = "1.15.13" +description = "The AWS SDK for Python" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +botocore = ">=1.18.13,<1.19.0" +jmespath = ">=0.7.1,<1.0.0" +s3transfer = ">=0.3.0,<0.4.0" + +[[package]] +name = "botocore" +version = "1.18.13" +description = "Low-level, data-driven core of boto 3." +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +jmespath = ">=0.7.1,<1.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.20,<1.26", markers = "python_version != \"3.4\""} + +[[package]] +name = "cairocffi" +version = "1.2.0" +description = "cffi-based cairo bindings for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.1.0" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"] +xcb = ["xcffib (>=0.3.2)"] + +[[package]] +name = "cairosvg" +version = "2.5.2" +description = "A Simple SVG Converter based on Cairo" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +cairocffi = "*" +cssselect2 = "*" +defusedxml = "*" +pillow = "*" +tinycss2 = "*" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"] + +[[package]] +name = "certifi" +version = "2020.6.20" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.14.5" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.3" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "cssselect2" +version = "0.4.1" +description = "cssselect2" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +tinycss2 = "*" +webencodings = "*" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"] + +[[package]] +name = "deepmerge" +version = "0.1.0" +description = "a toolset to deeply merge python dictionaries." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "dj-database-url" +version = "0.5.0" +description = "Use Database URLs in your Django Application." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "django" +version = "2.2.16" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +pytz = "*" +sqlparse = ">=0.2.2" + +[package.extras] +argon2 = ["argon2-cffi (>=16.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-anymail" +version = "7.2.1" +description = "Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +django = ">=1.11" +requests = ">=2.4.3" +six = "*" + +[package.extras] +amazon_ses = ["boto3"] +sparkpost = ["sparkpost"] + +[[package]] +name = "django-configurations" +version = "2.2" +description = "A helper for organizing Django settings." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +cache = ["django-cache-url"] +database = ["dj-database-url"] +email = ["dj-email-url"] +search = ["dj-search-url"] +testing = ["django-discover-runner", "mock", "django-cache-url (>=1.0.0)", "dj-database-url", "dj-email-url", "dj-search-url", "Sphinx (>=1.4)"] + +[[package]] +name = "django-cors-headers" +version = "3.5.0" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +Django = ">=2.2" + +[[package]] +name = "django-extensions" +version = "2.2.9" +description = "Extensions for Django" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.2" + +[[package]] +name = "django-filter" +version = "2.4.0" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +Django = ">=2.2" + +[[package]] +name = "django-fsm" +version = "2.7.0" +description = "Django friendly finite state machine support." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "django-model-utils" +version = "4.1.1" +description = "Django model mixins and utilities" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Django = ">=2.0.1" + +[[package]] +name = "django-nested-admin" +version = "3.3.2" +description = "Django admin classes that allow for nested inlines" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +python-monkey-business = ">=1.0.0" +six = "*" + +[[package]] +name = "django-polymorphic" +version = "2.1.2" +description = "Seamless polymorphic inheritance for Django models" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Django = ">=1.11" + +[[package]] +name = "django-rest-polymorphic" +version = "0.1.9" +description = "Polymorphic serializers for Django REST Framework." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +django = "*" +django-polymorphic = "*" +djangorestframework = "*" +six = "*" + +[[package]] +name = "django-silk" +version = "3.0.4" +description = "Silky smooth profiling for the Django Framework" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +autopep8 = "*" +Django = ">=1.11" +gprof2dot = ">=2017.09.19" +Jinja2 = "*" +Pygments = "*" +python-dateutil = "*" +pytz = "*" +requests = "*" +sqlparse = "*" + +[[package]] +name = "django-storages" +version = "1.10.1" +description = "Support for many storage backends in Django" +category = "main" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +Django = ">=2.2" + +[package.extras] +azure = ["azure-storage-blob (>=1.3.1,<12.0.0)"] +boto3 = ["boto3 (>=1.4.4)"] +dropbox = ["dropbox (>=7.2.1)"] +google = ["google-cloud-storage (>=1.15.0)"] +libcloud = ["apache-libcloud"] +sftp = ["paramiko"] + +[[package]] +name = "django-weasyprint" +version = "1.1.0" +description = "Django WeasyPrint CBV" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=2.2" +WeasyPrint = ">=43" + +[[package]] +name = "djangorestframework" +version = "3.12.1" +description = "Web APIs for Django, made easy." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +django = ">=2.2" + +[[package]] +name = "djangorestframework-camel-case" +version = "1.2.0" +description = "Camel case JSON support for Django REST framework." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "drf-extensions" +version = "0.5.0" +description = "Extensions for Django REST Framework" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +djangorestframework = ">=3.9.3" + +[[package]] +name = "factory-boy" +version = "2.12.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +Faker = ">=0.7.0" + +[[package]] +name = "faker" +version = "4.5.0" +description = "Faker is a Python package that generates fake data for you." +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +python-dateutil = ">=2.4" +text-unidecode = "1.3" + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[[package]] +name = "formencode" +version = "1.3.1" +description = "HTML form validation, generation, and conversion package" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +testing = ["nose", "pycountry", "dnspython"] + +[[package]] +name = "gprof2dot" +version = "2019.11.30" +description = "Generate a dot graph from the output of several profilers." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "gunicorn" +version = "19.10.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*" + +[package.extras] +eventlet = ["eventlet (>=0.9.7)"] +gevent = ["gevent (>=0.13)"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "html5lib" +version = "1.1" +description = "HTML parser based on the WHATWG HTML specification" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +six = ">=1.9" +webencodings = "*" + +[package.extras] +all = ["genshi", "chardet (>=2.2)", "lxml"] +chardet = ["chardet (>=2.2)"] +genshi = ["genshi"] +lxml = ["lxml"] + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "jinja2" +version = "2.11.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[package.extras] +i18n = ["Babel (>=0.8)"] + +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "markdown" +version = "3.3" +description = "Python implementation of Markdown." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "more-itertools" +version = "8.5.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "packaging" +version = "20.4" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +name = "pathspec" +version = "0.8.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pathtools" +version = "0.1.2" +description = "File system general utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pillow" +version = "6.2.2" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "psycopg2-binary" +version = "2.8.6" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[[package]] +name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycparser" +version = "2.20" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.7.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pyphen" +version = "0.10.0" +description = "Pure Python module to hyphenate text" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "2.10.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-django" +version = "3.10.0" +description = "A Django plugin for pytest." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pytest = ">=3.6" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["django", "django-configurations (>=2.0)", "six"] + +[[package]] +name = "pytest-env" +version = "0.6.2" +description = "py.test plugin that allows you to add environment variables." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=2.6.0" + +[[package]] +name = "pytest-factoryboy" +version = "2.0.3" +description = "Factory Boy support for pytest." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +factory_boy = ">=2.10.0" +inflection = "*" +pytest = ">=3.3.2" + +[[package]] +name = "pytest-sugar" +version = "0.9.4" +description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +packaging = ">=14.1" +pytest = ">=2.9" +termcolor = ">=1.1.0" + +[[package]] +name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-monkey-business" +version = "1.0.0" +description = "Utility functions for monkey-patching python code" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.7.0" + +[[package]] +name = "pytz" +version = "2020.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "5.3.1" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "regex" +version = "2020.9.27" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.24.0" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + +[[package]] +name = "rules" +version = "2.2" +description = "Awesome Django authorization, without the database" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "s3transfer" +version = "0.3.3" +description = "An Amazon S3 Transfer Manager" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[[package]] +name = "sentry-sdk" +version = "0.13.5" +description = "Python client for Sentry (https://getsentry.com)" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.10.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +flask = ["flask (>=0.11)", "blinker (>=1.1)"] +pyspark = ["pyspark (>=2.4.4)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sqlparse" +version = "0.4.0" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "tinycss2" +version = "1.1.0" +description = "tinycss2" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"] + +[[package]] +name = "toml" +version = "0.10.1" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typed-ast" +version = "1.4.1" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "uritemplate" +version = "3.0.1" +description = "URI templates" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "urllib3" +version = "1.25.10" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "watchdog" +version = "0.9.0" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +argh = ">=0.24.1" +pathtools = ">=0.1.1" +PyYAML = ">=3.10" + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "weasyprint" +version = "52.5" +description = "The Awesome Document Factory" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cairocffi = ">=0.9.0" +CairoSVG = ">=2.4.0" +cffi = ">=0.6" +cssselect2 = ">=0.1" +html5lib = ">=0.999999999" +Pillow = ">=4.0.0" +Pyphen = ">=0.9.1" +tinycss2 = ">=1.0.0" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "werkzeug" +version = "0.16.1" +description = "The comprehensive WSGI web application library." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] +termcolor = ["termcolor"] +watchdog = ["watchdog"] + +[extras] +production = ["django-anymail", "psycopg2-binary", "gunicorn", "sentry-sdk", "django-storages", "boto3", "pyyaml", "uritemplate"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.8" +content-hash = "3ed762811d5241ef3d7276f24b16d9d9ffa3f9c86b0c744a1baf5dd83a000bd3" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +argh = [ + {file = "argh-0.26.2-py2.py3-none-any.whl", hash = "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3"}, + {file = "argh-0.26.2.tar.gz", hash = "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, + {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, +] +autopep8 = [ + {file = "autopep8-1.5.4.tar.gz", hash = "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094"}, +] +black = [ + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, +] +boto3 = [ + {file = "boto3-1.15.13-py2.py3-none-any.whl", hash = "sha256:f71ef9005bc49f92e822b3c24d651a9bc2b682a63acdcbbb2bc1d16da39c01ea"}, + {file = "boto3-1.15.13.tar.gz", hash = "sha256:329178bd505b5d1dbb5eab25e9697c0e592c45548c895f451a91772d0e5a9329"}, +] +botocore = [ + {file = "botocore-1.18.13-py2.py3-none-any.whl", hash = "sha256:90e76683807896a28bbefd6e9bc3a2dc44ecd88fc6f6737fa0e3b20d5b7223ea"}, + {file = "botocore-1.18.13.tar.gz", hash = "sha256:570609095c992732cf6158c3dd0a38619c77818fa700d6779b27277b34f98959"}, +] +cairocffi = [ + {file = "cairocffi-1.2.0.tar.gz", hash = "sha256:9a979b500c64c8179fec286f337e8fe644eca2f2cd05860ce0b62d25f22ea140"}, +] +cairosvg = [ + {file = "CairoSVG-2.5.2-py3-none-any.whl", hash = "sha256:98c276b7e4f0caf01e5c7176765c104ffa1aa1461d63b2053b04ab663cf7052b"}, + {file = "CairoSVG-2.5.2.tar.gz", hash = "sha256:b0b9929cf5dba005178d746a8036fcf0025550f498ca54db61873322384783bc"}, +] +certifi = [ + {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, + {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, +] +cffi = [ + {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, + {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, + {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, + {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, + {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, + {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, + {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, + {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, + {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, + {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, + {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, + {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, + {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, + {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, + {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, + {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, + {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, + {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, + {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, + {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, + {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, + {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, + {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, + {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, + {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] +cssselect2 = [ + {file = "cssselect2-0.4.1-py3-none-any.whl", hash = "sha256:2f4a9f20965367bae459e3bb42561f7927e0cfe5b7ea1692757cf67ef5d7dace"}, + {file = "cssselect2-0.4.1.tar.gz", hash = "sha256:93fbb9af860e95dd40bf18c3b2b6ed99189a07c0f29ba76f9c5be71344664ec8"}, +] +deepmerge = [ + {file = "deepmerge-0.1.0-py2.py3-none-any.whl", hash = "sha256:ae23dd76d3c0d22d33a3fd3980c92d3f0773e4affb48d9b341847d0b0a24e8f8"}, + {file = "deepmerge-0.1.0.tar.gz", hash = "sha256:3d37f739e74e8a284ee0bd683daaef88acc8438ba048545aefb87ade695a2a34"}, +] +defusedxml = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] +dj-database-url = [ + {file = "dj-database-url-0.5.0.tar.gz", hash = "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163"}, + {file = "dj_database_url-0.5.0-py2.py3-none-any.whl", hash = "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9"}, +] +django = [ + {file = "Django-2.2.16-py3-none-any.whl", hash = "sha256:83ced795a0f239f41d8ecabf51cc5fad4b97462a6008dc12e5af3cb9288724ec"}, + {file = "Django-2.2.16.tar.gz", hash = "sha256:62cf45e5ee425c52e411c0742e641a6588b7e8af0d2c274a27940931b2786594"}, +] +django-anymail = [ + {file = "django-anymail-7.2.1.tar.gz", hash = "sha256:7fadf9781573b30af5bb6ca2f39b345c4ca2547723ce677c3025292d0a309a96"}, + {file = "django_anymail-7.2.1-py2.py3-none-any.whl", hash = "sha256:dc5d835049a5dbf738a0f4bd793c4655cd53ba455e2ead275845bc158d48cc63"}, +] +django-configurations = [ + {file = "django-configurations-2.2.tar.gz", hash = "sha256:9e3bcea1355ac50a4c9f854f751d214cb17e5f8adf18405a4488d0a1e8945915"}, + {file = "django_configurations-2.2-py2.py3-none-any.whl", hash = "sha256:0b93cb7042739e2d69cfc6fb81676bbd3cbb63ea19b02e8b681f4cad3a8aaf1b"}, +] +django-cors-headers = [ + {file = "django-cors-headers-3.5.0.tar.gz", hash = "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a"}, + {file = "django_cors_headers-3.5.0-py3-none-any.whl", hash = "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169"}, +] +django-extensions = [ + {file = "django-extensions-2.2.9.tar.gz", hash = "sha256:2f81b618ba4d1b0e58603e25012e5c74f88a4b706e0022a3b21f24f0322a6ce6"}, + {file = "django_extensions-2.2.9-py2.py3-none-any.whl", hash = "sha256:b19182d101a441fe001c5753553a901e2ef3ff60e8fbbe38881eb4a61fdd17c4"}, +] +django-filter = [ + {file = "django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06"}, + {file = "django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"}, +] +django-fsm = [ + {file = "django-fsm-2.7.0.tar.gz", hash = "sha256:9f42111814a273cdfd79cdf839f3596bc48b9cfe5fe5102293f07ade5b0048d5"}, + {file = "django_fsm-2.7.0-py2.py3-none-any.whl", hash = "sha256:d5b0ca13965261f4fffad350fadd388dcfd9b6712399a84021073a40b161fbd7"}, +] +django-model-utils = [ + {file = "django-model-utils-4.1.1.tar.gz", hash = "sha256:eb5dd05ef7d7ce6bc79cae54ea7c4a221f6f81e2aad7722933aee66489e7264b"}, + {file = "django_model_utils-4.1.1-py3-none-any.whl", hash = "sha256:ef7c440024e797796a3811432abdd2be8b5225ae64ef346f8bfc6de7d8e5d73c"}, +] +django-nested-admin = [ + {file = "django-nested-admin-3.3.2.tar.gz", hash = "sha256:7ec58af67107e6d7b2d0fd4fe03629eff8f7957c4aef5033c4700da1bd2d7870"}, + {file = "django_nested_admin-3.3.2-py2.py3-none-any.whl", hash = "sha256:2e6706050057f5dc5664bc90d034d4ed9e3ae032ead6b063da9982c9e45ab7f3"}, +] +django-polymorphic = [ + {file = "django-polymorphic-2.1.2.tar.gz", hash = "sha256:6e08a76c91066635ccb7ef3ebbe9a0ad149febae6b30be2579716ec16d3c6461"}, + {file = "django_polymorphic-2.1.2-py2.py3-none-any.whl", hash = "sha256:0a25058e95e5e99fe0beeabb8f4734effe242d7b5b77dca416fba9fd3062da6a"}, +] +django-rest-polymorphic = [ + {file = "django-rest-polymorphic-0.1.9.tar.gz", hash = "sha256:43d7f8c5b43a225fe792ac40e98d196d4c8d6872a796b61246085819b1b5f1c6"}, + {file = "django_rest_polymorphic-0.1.9-py2.py3-none-any.whl", hash = "sha256:ae9dd7a104bd6d2faac3bb7cdc31d3c3c768a4bb25f75451e866d9fab66fb6eb"}, +] +django-silk = [ + {file = "django-silk-3.0.4.tar.gz", hash = "sha256:55a0e847710ea43b6c46e6f86bea8b4fba88bffe261c9bd9b21de599ca76dc3c"}, + {file = "django_silk-3.0.4-py2.py3-none-any.whl", hash = "sha256:25fca5307d2904c347f88b4848302ab576d0bf598f3c0c12f1726478af5c069f"}, +] +django-storages = [ + {file = "django-storages-1.10.1.tar.gz", hash = "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18"}, + {file = "django_storages-1.10.1-py3-none-any.whl", hash = "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c"}, +] +django-weasyprint = [ + {file = "django-weasyprint-1.1.0.tar.gz", hash = "sha256:7bda5d9d11e11edbe54630c9e41454d72293a70c40bd8c1c1156f3c5c6eec2e1"}, + {file = "django_weasyprint-1.1.0-py3-none-any.whl", hash = "sha256:a1f1bacf1c0d21904a2c2fb6488d97b126d4c823e8cf632cbb5e0d8b3354eeff"}, +] +djangorestframework = [ + {file = "djangorestframework-3.12.1-py3-none-any.whl", hash = "sha256:5c5071fcbad6dce16f566d492015c829ddb0df42965d488b878594aabc3aed21"}, + {file = "djangorestframework-3.12.1.tar.gz", hash = "sha256:d54452aedebb4b650254ca092f9f4f5df947cb1de6ab245d817b08b4f4156249"}, +] +djangorestframework-camel-case = [ + {file = "djangorestframework-camel-case-1.2.0.tar.gz", hash = "sha256:9714d43fba5bb654057c29501649684d3d9f11a92319ae417fd4d65e80d1159d"}, +] +drf-extensions = [ + {file = "drf-extensions-0.5.0.tar.gz", hash = "sha256:a3869f2bb668ee05acc78e9076e658527611efa9a4ba319f0817f89b0ec01bf4"}, + {file = "drf_extensions-0.5.0-py2.py3-none-any.whl", hash = "sha256:78e3ac3c944774878c28ae335d91bbd6c43889bee610c48cf893faffb7685dce"}, +] +factory-boy = [ + {file = "factory_boy-2.12.0-py2.py3-none-any.whl", hash = "sha256:728df59b372c9588b83153facf26d3d28947fc750e8e3c95cefa9bed0e6394ee"}, + {file = "factory_boy-2.12.0.tar.gz", hash = "sha256:faf48d608a1735f0d0a3c9cbf536d64f9132b547dae7ba452c4d99a79e84a370"}, +] +faker = [ + {file = "Faker-4.5.0-py3-none-any.whl", hash = "sha256:ba4f98ed8c4c3e817c39626e996a913e3b6478b02625e1cfe9feb7fd8f2f4883"}, + {file = "Faker-4.5.0.tar.gz", hash = "sha256:c7c7dcede34e10bfdb8601bea807bf065ec3dd3e90884aabcfd4e3f7ae2dbe3b"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] +formencode = [ + {file = "FormEncode-1.3.1.tar.gz", hash = "sha256:ada2f51792b1b484e5bb7b6cc14acfc1bc11fafc967cf015cd57e856053ca7f6"}, +] +gprof2dot = [ + {file = "gprof2dot-2019.11.30.tar.gz", hash = "sha256:b43fe04ebb3dfe181a612bbfc69e90555b8957022ad6a466f0308ed9c7f22e99"}, +] +gunicorn = [ + {file = "gunicorn-19.10.0-py2.py3-none-any.whl", hash = "sha256:c3930fe8de6778ab5ea716cab432ae6335fa9f03b3f2c3e02529214c476f4bcb"}, + {file = "gunicorn-19.10.0.tar.gz", hash = "sha256:f9de24e358b841567063629cd0a656b26792a41e23a24d0dcb40224fc3940081"}, +] +html5lib = [ + {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, + {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, +] +idna = [ + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, +] +inflection = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] +jinja2 = [ + {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, + {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, +] +jmespath = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] +markdown = [ + {file = "Markdown-3.3-py3-none-any.whl", hash = "sha256:fbb1ba54ca41e8991dc5a561d9c6f752f5e4546f8750e56413ea50f2385761d3"}, + {file = "Markdown-3.3.tar.gz", hash = "sha256:4f4172a4e989b97f96860fa434b89895069c576e2b537c4b4eed265266a7affc"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +more-itertools = [ + {file = "more-itertools-8.5.0.tar.gz", hash = "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20"}, + {file = "more_itertools-8.5.0-py3-none-any.whl", hash = "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +pathspec = [ + {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, + {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, +] +pathtools = [ + {file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"}, +] +pillow = [ + {file = "Pillow-6.2.2-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:834dd023b7f987d6b700ad93dc818098d7eb046bd445e9992b3093c6f9d7a95f"}, + {file = "Pillow-6.2.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:d3a98444a00b4643b22b0685dbf9e0ddcaf4ebfd4ea23f84f228adf5a0765bb2"}, + {file = "Pillow-6.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2b4a94be53dff02af90760c10a2e3634c3c7703410f38c98154d5ce71fe63d20"}, + {file = "Pillow-6.2.2-cp27-cp27m-win32.whl", hash = "sha256:87ef0eca169f7f0bc050b22f05c7e174a65c36d584428431e802c0165c5856ea"}, + {file = "Pillow-6.2.2-cp27-cp27m-win_amd64.whl", hash = "sha256:cbd5647097dc55e501f459dbac7f1d0402225636deeb9e0a98a8d2df649fc19d"}, + {file = "Pillow-6.2.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:4adc3302df4faf77c63ab3a83e1a3e34b94a6a992084f4aa1cb236d1deaf4b39"}, + {file = "Pillow-6.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e3a797a079ce289e59dbd7eac9ca3bf682d52687f718686857281475b7ca8e6a"}, + {file = "Pillow-6.2.2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:bb7861e4618a0c06c40a2e509c1bea207eea5fd4320d486e314e00745a402ca5"}, + {file = "Pillow-6.2.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:535e8e0e02c9f1fc2e307256149d6ee8ad3aa9a6e24144b7b6e6fb6126cb0e99"}, + {file = "Pillow-6.2.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:bc149dab804291a18e1186536519e5e122a2ac1316cb80f506e855a500b1cdd4"}, + {file = "Pillow-6.2.2-cp35-cp35m-win32.whl", hash = "sha256:1a3bc8e1db5af40a81535a62a591fafdb30a8a1b319798ea8052aa65ef8f06d2"}, + {file = "Pillow-6.2.2-cp35-cp35m-win_amd64.whl", hash = "sha256:d6b4dc325170bee04ca8292bbd556c6f5398d52c6149ca881e67daf62215426f"}, + {file = "Pillow-6.2.2-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:43ef1cff7ee57f9c8c8e6fa02a62eae9fa23a7e34418c7ce88c0e3fe09d1fb38"}, + {file = "Pillow-6.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:900de1fdc93764be13f6b39dc0dd0207d9ff441d87ad7c6e97e49b81987dc0f3"}, + {file = "Pillow-6.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b83b380f9181cacc994f4c983d95a9c8b00b50bf786c66d235716b526a3332"}, + {file = "Pillow-6.2.2-cp36-cp36m-win32.whl", hash = "sha256:00e0bbe9923adc5cc38a8da7d87d4ce16cde53b8d3bba8886cb928e84522d963"}, + {file = "Pillow-6.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:5ccfcb0a34ad9b77ad247c231edb781763198f405a5c8dc1b642449af821fb7f"}, + {file = "Pillow-6.2.2-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:5dcbbaa3a24d091a64560d3c439a8962866a79a033d40eb1a75f1b3413bfc2bc"}, + {file = "Pillow-6.2.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6e2a7e74d1a626b817ecb7a28c433b471a395c010b2a1f511f976e9ea4363e64"}, + {file = "Pillow-6.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c424d35a5259be559b64490d0fd9e03fba81f1ce8e5b66e0a59de97547351d80"}, + {file = "Pillow-6.2.2-cp37-cp37m-win32.whl", hash = "sha256:aa4792ab056f51b49e7d59ce5733155e10a918baf8ce50f64405db23d5627fa2"}, + {file = "Pillow-6.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:0d5c99f80068f13231ac206bd9b2e80ea357f5cf9ae0fa97fab21e32d5b61065"}, + {file = "Pillow-6.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03457e439d073770d88afdd90318382084732a5b98b0eb6f49454746dbaae701"}, + {file = "Pillow-6.2.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ccf16fe444cc43800eeacd4f4769971200982200a71b1368f49410d0eb769543"}, + {file = "Pillow-6.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b72c39585f1837d946bd1a829a4820ccf86e361f28cbf60f5d646f06318b61e2"}, + {file = "Pillow-6.2.2-cp38-cp38-win32.whl", hash = "sha256:3ba7d8f1d962780f86aa747fef0baf3211b80cb13310fff0c375da879c0656d4"}, + {file = "Pillow-6.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:3e81485cec47c24f5fb27acb485a4fc97376b2b332ed633867dc68ac3077998c"}, + {file = "Pillow-6.2.2-pp273-pypy_73-win32.whl", hash = "sha256:aa1b0297e352007ec781a33f026afbb062a9a9895bb103c8f49af434b1666880"}, + {file = "Pillow-6.2.2-pp373-pypy36_pp73-win32.whl", hash = "sha256:82859575005408af81b3e9171ae326ff56a69af5439d3fc20e8cb76cd51c8246"}, + {file = "Pillow-6.2.2.tar.gz", hash = "sha256:db9ff0c251ed066d367f53b64827cc9e18ccea001b986d08c265e53625dab950"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win32.whl", hash = "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win_amd64.whl", hash = "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win32.whl", hash = "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win_amd64.whl", hash = "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win32.whl", hash = "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win_amd64.whl", hash = "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win32.whl", hash = "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win_amd64.whl", hash = "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win32.whl", hash = "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win32.whl", hash = "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pycparser = [ + {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, + {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pygments = [ + {file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"}, + {file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyphen = [ + {file = "Pyphen-0.10.0-py3-none-any.whl", hash = "sha256:624b036eafe6f38fad3e79ee676ec711a8ce634feb9f34ac615bbaf73e662111"}, + {file = "Pyphen-0.10.0.tar.gz", hash = "sha256:719b21dfb4b04fbc11cc0f6112418535fe35474021120cccfffc43a25fe63128"}, +] +pytest = [ + {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, + {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, +] +pytest-cov = [ + {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, + {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, +] +pytest-django = [ + {file = "pytest-django-3.10.0.tar.gz", hash = "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6"}, + {file = "pytest_django-3.10.0-py2.py3-none-any.whl", hash = "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4"}, +] +pytest-env = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] +pytest-factoryboy = [ + {file = "pytest-factoryboy-2.0.3.tar.gz", hash = "sha256:ffef3fb7ddec1299d3df0d334846259023f3d1da5ab887ad880139a8253a5a1a"}, +] +pytest-sugar = [ + {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +python-monkey-business = [ + {file = "python-monkey-business-1.0.0.tar.gz", hash = "sha256:9976522989766f00b2aaa24ec96eacb91a6de7b7001d1452079323b071988e0e"}, + {file = "python_monkey_business-1.0.0-py2.py3-none-any.whl", hash = "sha256:6d4cf47f011945db838ccf04643acd49b82f7ad6ab7ecba4c8165385687a828a"}, +] +pytz = [ + {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, + {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, +] +pyyaml = [ + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a"}, + {file = "PyYAML-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, +] +regex = [ + {file = "regex-2020.9.27-cp27-cp27m-win32.whl", hash = "sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3"}, + {file = "regex-2020.9.27-cp27-cp27m-win_amd64.whl", hash = "sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19"}, + {file = "regex-2020.9.27-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be"}, + {file = "regex-2020.9.27-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b"}, + {file = "regex-2020.9.27-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc"}, + {file = "regex-2020.9.27-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b"}, + {file = "regex-2020.9.27-cp36-cp36m-win32.whl", hash = "sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63"}, + {file = "regex-2020.9.27-cp36-cp36m-win_amd64.whl", hash = "sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b"}, + {file = "regex-2020.9.27-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7"}, + {file = "regex-2020.9.27-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc"}, + {file = "regex-2020.9.27-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c"}, + {file = "regex-2020.9.27-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100"}, + {file = "regex-2020.9.27-cp37-cp37m-win32.whl", hash = "sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707"}, + {file = "regex-2020.9.27-cp37-cp37m-win_amd64.whl", hash = "sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab"}, + {file = "regex-2020.9.27-cp38-cp38-manylinux1_i686.whl", hash = "sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef"}, + {file = "regex-2020.9.27-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121"}, + {file = "regex-2020.9.27-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b"}, + {file = "regex-2020.9.27-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637"}, + {file = "regex-2020.9.27-cp38-cp38-win32.whl", hash = "sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f"}, + {file = "regex-2020.9.27-cp38-cp38-win_amd64.whl", hash = "sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c"}, + {file = "regex-2020.9.27-cp39-cp39-manylinux1_i686.whl", hash = "sha256:84cada8effefe9a9f53f9b0d2ba9b7b6f5edf8d2155f9fdbe34616e06ececf81"}, + {file = "regex-2020.9.27-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:816064fc915796ea1f26966163f6845de5af78923dfcecf6551e095f00983650"}, + {file = "regex-2020.9.27-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:5d892a4f1c999834eaa3c32bc9e8b976c5825116cde553928c4c8e7e48ebda67"}, + {file = "regex-2020.9.27-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c9443124c67b1515e4fe0bb0aa18df640965e1030f468a2a5dc2589b26d130ad"}, + {file = "regex-2020.9.27-cp39-cp39-win32.whl", hash = "sha256:49f23ebd5ac073765ecbcf046edc10d63dcab2f4ae2bce160982cb30df0c0302"}, + {file = "regex-2020.9.27-cp39-cp39-win_amd64.whl", hash = "sha256:3d20024a70b97b4f9546696cbf2fd30bae5f42229fbddf8661261b1eaff0deb7"}, + {file = "regex-2020.9.27.tar.gz", hash = "sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d"}, +] +requests = [ + {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, + {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, +] +rules = [ + {file = "rules-2.2.tar.gz", hash = "sha256:9bae429f9d4f91a375402990da1541f9e093b0ac077221d57124d06eeeca4405"}, +] +s3transfer = [ + {file = "s3transfer-0.3.3-py2.py3-none-any.whl", hash = "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13"}, + {file = "s3transfer-0.3.3.tar.gz", hash = "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"}, +] +sentry-sdk = [ + {file = "sentry-sdk-0.13.5.tar.gz", hash = "sha256:c6b919623e488134a728f16326c6f0bcdab7e3f59e7f4c472a90eea4d6d8fe82"}, + {file = "sentry_sdk-0.13.5-py2.py3-none-any.whl", hash = "sha256:05285942901d38c7ce2498aba50d8e87b361fc603281a5902dda98f3f8c5e145"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +sqlparse = [ + {file = "sqlparse-0.4.0-py3-none-any.whl", hash = "sha256:0523026398aea9c8b5f7a4a6d5c0829c285b4fbd960c17b5967a369342e21e01"}, + {file = "sqlparse-0.4.0.tar.gz", hash = "sha256:d59e473424ae7470778fa4dd0dd7bb666ff324f0a6106c29deb5946ea5367f04"}, +] +termcolor = [ + {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, +] +text-unidecode = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] +tinycss2 = [ + {file = "tinycss2-1.1.0-py3-none-any.whl", hash = "sha256:0353b5234bcaee7b1ac7ca3dea7e02cd338a9f8dcbb8f2dcd32a5795ec1e5f9a"}, + {file = "tinycss2-1.1.0.tar.gz", hash = "sha256:fbdcac3044d60eb85fdb2aa840ece43cf7dbe798e373e6ee0be545d4d134e18a"}, +] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, + {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, + {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, + {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +uritemplate = [ + {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, + {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, +] +urllib3 = [ + {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, + {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, +] +watchdog = [ + {file = "watchdog-0.9.0.tar.gz", hash = "sha256:965f658d0732de3188211932aeb0bb457587f04f63ab4c1e33eab878e9de961d"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +weasyprint = [ + {file = "WeasyPrint-52.5-py3-none-any.whl", hash = "sha256:3433d657049a65d7d63f545fd71f5efa8aae7f05d24e49e01a757973fd3799f1"}, + {file = "WeasyPrint-52.5.tar.gz", hash = "sha256:b37ea02d75ca04babd7becad7341426be332ae560d8f02d664bfa1e9afb18481"}, +] +webencodings = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] +werkzeug = [ + {file = "Werkzeug-0.16.1-py2.py3-none-any.whl", hash = "sha256:1e0dedc2acb1f46827daa2e399c1485c8fa17c0d8e70b6b875b4e7f54bf408d2"}, + {file = "Werkzeug-0.16.1.tar.gz", hash = "sha256:b353856d37dec59d6511359f97f6a4b2468442e454bd1c98298ddce53cac1f04"}, +] diff --git a/evg-outbo-api/pyproject.toml b/evg-outbo-api/pyproject.toml new file mode 100755 index 0000000..05ea522 --- /dev/null +++ b/evg-outbo-api/pyproject.toml @@ -0,0 +1,66 @@ +[tool.poetry] +name = "evg-outbo-api" +version = "3.0.0-beta0" +description = "Outbo API" +authors = ["Łukasz Wolak ", "Michał Salej "] + +[tool.poetry.dependencies] +python = "^3.8" +django = "^2.2" +djangorestframework = "^3.10" +django-configurations = "^2.1" +dj-database-url = "^0.5.0" +markdown = "^3.1" +django-extensions = "^2.2" +djangorestframework-camel-case = "^1.1" +rules = "^2.1" +django-fsm = "^2.6" +pillow = "^6.2" +psycopg2-binary = { version = "^2.8" } +gunicorn = { version = "^19.9", optional = true } +pyyaml = { version = "^5.1", optional = true } +uritemplate = { version = "^3.0", optional = true } +django-cors-headers = "^3.1" +sentry-sdk = { version = "^0.13.0", optional = true } +drf-extensions = "^0.5.0" +django-storages = { version = "^1.7", optional = true } +boto3 = { version = "^1.10", optional = true } +django-anymail = { version = "^7.0", optional = true, extras = ["mailgun"] } +django-polymorphic = "^2.1" +django-rest-polymorphic = "^0.1.8" +django-nested-admin = "^3.2" +django-filter = "^2.2" +factory_boy = "^2.12" +formencode = "^1.3" +deepmerge = "^0.1.0" +django-model-utils = "^4.1.1" +django-weasyprint = "^1.1.0" + +[tool.poetry.dev-dependencies] +pytest = "^5.2" +black = "=19.10b0" +pytest-cov = "^2.8" +pytest-django = "^3.6" +pytest-env = "^0.6.2" +pytest-factoryboy = "^2.0" +flake8 = "^3.7" +pytest-sugar = "^0.9.2" +django-silk = "^3.0" +werkzeug = "^0.16.0" +watchdog = "^0.9.0" + +[tool.poetry.extras] +production = [ + "django-anymail", + "psycopg2-binary", + "gunicorn", + "sentry-sdk", + "django-storages", + "boto3", + "pyyaml", + "uritemplate" +] + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/evg-outbo-api/pytest.ini b/evg-outbo-api/pytest.ini new file mode 100755 index 0000000..5d68bcb --- /dev/null +++ b/evg-outbo-api/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +DJANGO_CONFIGURATION = Test +DJANGO_SETTINGS_MODULE = outbo.settings +python_classes = Test* *Tests +python_files = tests.py test_*.py *_tests.py +addopts = --nomigrations --cov-report=html --disable-warnings +env = + D:DJANGO_SECRET_KEY='super-secret-key' \ No newline at end of file diff --git a/evg-outbo-api/tests/__init__.py b/evg-outbo-api/tests/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/evg-outbo-api/tests/conftest.py b/evg-outbo-api/tests/conftest.py new file mode 100755 index 0000000..fa2d53a --- /dev/null +++ b/evg-outbo-api/tests/conftest.py @@ -0,0 +1,58 @@ +from pytest import fixture + +from pytest_factoryboy import register, LazyFixture +from tests.factories.authentication import ( + UserFactory, + AgencyUserFactory, + ClientUserFactory, + SupplierUserFactory, +) +from tests.factories.organizations import ( + AgencyOrganizationFactory, + ClientOrganizationFactory, + SupplierOrganizationFactory, +) +from tests.factories.products import ProductStageFactory, ProductFactory +from tests.factories.projects import ( + CategoryFactory, + BrandFactory, + ProjectFactory, +) +from tests.factories.token import TokenFactory + + +register(UserFactory) +register(AgencyUserFactory, "agency_user") +register(ClientUserFactory, "client_user") +register(SupplierUserFactory, "supplier_user") + +register(TokenFactory) + +register(AgencyOrganizationFactory) +register(ClientOrganizationFactory) +register(SupplierOrganizationFactory) + +register(CategoryFactory) +register(BrandFactory) +register(ProjectFactory) +register(ProjectFactory, "published_project", published=True) + +register(ProductFactory) +register(ProductFactory, "published_product", project=LazyFixture("published_project")) +register(ProductStageFactory) + + +@fixture +def product_stages(product_stage_factory): + product_stage_factory( + id="waiting_for_brief", + label="1", + group=1, + description="Briefing", + next_action="Waiting for brief", + ) + + +@fixture +def unauthorized_message(): + return "You do not have permission to perform this action." diff --git a/evg-outbo-api/tests/e2e/authentication/user_login_tests.py b/evg-outbo-api/tests/e2e/authentication/user_login_tests.py new file mode 100755 index 0000000..6027d6d --- /dev/null +++ b/evg-outbo-api/tests/e2e/authentication/user_login_tests.py @@ -0,0 +1,30 @@ +import pytest +from rest_framework import status +from rest_framework.utils import json + + +@pytest.mark.django_db +class UserLoginE2ETests: + def test_missing_username_and_password_fail_validation(self, client): + # Arrange + # Act + response = client.post( + "/token/", data=json.dumps({}), content_type="application/json" + ) + # Assert + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_valid_credentials_return_token(self, user_factory, client): + # Arrange + user_factory(username="username", password="valid_password") + + # Act + response = client.post( + "/token/", + data=json.dumps({"username": "username", "password": "valid_password"}), + content_type="application/json", + ) + data = response.json() + # Assert + assert response.status_code == status.HTTP_200_OK + assert "token" in data diff --git a/evg-outbo-api/tests/e2e/products/get_product_brief_tests.py b/evg-outbo-api/tests/e2e/products/get_product_brief_tests.py new file mode 100755 index 0000000..4a13b5e --- /dev/null +++ b/evg-outbo-api/tests/e2e/products/get_product_brief_tests.py @@ -0,0 +1,60 @@ +import pytest + +from pytest import fixture +from pytest_factoryboy import LazyFixture +from rest_framework import status + + +@pytest.mark.django_db +class GetProductBriefE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture + def brief_url(self, product): + return f"/briefs/{product.id}/" + + @fixture + def published_brief_url(self, published_product): + return f"/briefs/{published_product.id}/" + + def test_unauthorized_cannot_open_brief(self, token, client, brief_url): + response = client.get(path=brief_url, content_type="application/json",) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.parametrize( + "token__user,expected_status_code", + [ + (LazyFixture("client_user"), status.HTTP_403_FORBIDDEN), + (LazyFixture("agency_user"), status.HTTP_200_OK), + (LazyFixture("supplier_user"), status.HTTP_200_OK), + ], + ) + def test_only_supplier_and_agency_can_open_nonpublished_brief( + self, token, client, brief_url, expected_status_code + ): + response = client.get( + path=brief_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + "token__user,expected_status_code", + [ + (LazyFixture("client_user"), status.HTTP_200_OK), + (LazyFixture("agency_user"), status.HTTP_200_OK), + (LazyFixture("supplier_user"), status.HTTP_200_OK), + ], + ) + def test_everyone_can_open_published_brief( + self, token, client, published_brief_url, expected_status_code + ): + response = client.get( + path=published_brief_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + assert response.status_code == expected_status_code diff --git a/evg-outbo-api/tests/e2e/products/get_product_by_id_tests.py b/evg-outbo-api/tests/e2e/products/get_product_by_id_tests.py new file mode 100755 index 0000000..fa0701f --- /dev/null +++ b/evg-outbo-api/tests/e2e/products/get_product_by_id_tests.py @@ -0,0 +1,38 @@ +import pytest + +from pytest import fixture +from pytest_factoryboy import LazyFixture +from rest_framework import status + + +@pytest.mark.django_db +class GetProductE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture + def request_url(self, product): + return f"/products/{product.id}/" + + @pytest.mark.parametrize( + "token__user,project__published,expected_status_code", + [ + (LazyFixture("agency_user"), False, status.HTTP_200_OK), + (LazyFixture("client_user"), False, status.HTTP_403_FORBIDDEN), + (LazyFixture("supplier_user"), False, status.HTTP_200_OK), + (LazyFixture("agency_user"), True, status.HTTP_200_OK), + (LazyFixture("client_user"), True, status.HTTP_200_OK), + (LazyFixture("supplier_user"), True, status.HTTP_200_OK), + ], + ) + def test_user_can_see_product( + self, client, request_url, expected_status_code, token, project + ): + response = client.get( + path=request_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + assert response.status_code == expected_status_code diff --git a/evg-outbo-api/tests/e2e/products/get_products_for_project_tests.py b/evg-outbo-api/tests/e2e/products/get_products_for_project_tests.py new file mode 100755 index 0000000..52c1d1d --- /dev/null +++ b/evg-outbo-api/tests/e2e/products/get_products_for_project_tests.py @@ -0,0 +1,56 @@ +import pytest +from pytest import fixture +from pytest_factoryboy import LazyFixture +from rest_framework import status + + +@pytest.mark.django_db +class GetProductsListForProjectE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture(autouse=True) + def project_with_two_products(self, project_factory): + return project_factory(products_amount=2, published=False) + + @fixture(autouse=True) + def another_project_with_three_products(self, project_factory): + project_factory(products_amount=3, published=False) + + @fixture + def request_url(self, project_with_two_products): + return f"/products/?project={project_with_two_products.id}" + + def test_anonymous_user_is_not_authorized(self, client, request_url): + response = client.get(request_url, content_type="application/json") + + # Assert + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), "Wrong HTTP response code." + + @pytest.mark.parametrize( + "token__user,expected_count", + [ + (LazyFixture("agency_user"), 2), + (LazyFixture("client_user"), 0), + (LazyFixture("supplier_user"), 2), + ], + ) + def test_user_sees_only_published_products( + self, client, request_url, token, expected_count + ): + # Arrange + + # Act + response = client.get( + path=request_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert response.status_code == status.HTTP_200_OK + assert len(data) == expected_count diff --git a/evg-outbo-api/tests/e2e/products/update_product_brief_tests.py b/evg-outbo-api/tests/e2e/products/update_product_brief_tests.py new file mode 100755 index 0000000..3f3f8f3 --- /dev/null +++ b/evg-outbo-api/tests/e2e/products/update_product_brief_tests.py @@ -0,0 +1,21 @@ +import pytest + +from pytest import fixture +from pytest_factoryboy import LazyFixture +from rest_framework import status + + +@pytest.mark.skip("Not yet implemented") +@pytest.mark.django_db +class UpdateProductBriefE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture + def brief_url(self, product): + return f"/briefs/{product.id}/" + + @fixture + def published_brief_url(self, published_product): + return f"/briefs/{published_product.id}/" diff --git a/evg-outbo-api/tests/e2e/projects/get_project_by_id_tests.py b/evg-outbo-api/tests/e2e/projects/get_project_by_id_tests.py new file mode 100755 index 0000000..3aeeeba --- /dev/null +++ b/evg-outbo-api/tests/e2e/projects/get_project_by_id_tests.py @@ -0,0 +1,54 @@ +import pytest + +from pytest import fixture +from pytest_factoryboy import LazyFixture + + +@pytest.mark.django_db +class GetProductByIdE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture + def request_url(self, project): + return f"/projects/{project.id}/" + + @pytest.mark.parametrize( + "token__user,project__published,expected_status_code", + [ + (LazyFixture("agency_user"), False, 200), + (LazyFixture("supplier_user"), False, 200), + (LazyFixture("agency_user"), True, 200), + (LazyFixture("client_user"), True, 200), + (LazyFixture("supplier_user"), True, 200), + ], + ) + def test_user_can_see_project( + self, client, request_url, expected_status_code, token + ): + response = client.get( + path=request_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + "token__user,project__published", + [ + (LazyFixture("agency_user"), False), + (LazyFixture("supplier_user"), False), + (LazyFixture("agency_user"), True), + (LazyFixture("client_user"), True), + (LazyFixture("supplier_user"), True), + ], + ) + def test_user_sees_correct_project(self, client, request_url, token, project): + response = client.get( + path=request_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + assert data["slug"] == project.slug diff --git a/evg-outbo-api/tests/e2e/projects/get_projects_list_tests.py b/evg-outbo-api/tests/e2e/projects/get_projects_list_tests.py new file mode 100755 index 0000000..8e9b4df --- /dev/null +++ b/evg-outbo-api/tests/e2e/projects/get_projects_list_tests.py @@ -0,0 +1,45 @@ +import pytest + +from pytest import fixture +from pytest_factoryboy import LazyFixture +from rest_framework import status + + +@pytest.mark.django_db +class GetProjectsListE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture + def request_url(self): + return f"/projects/" + + @fixture(autouse=True) + def published_and_unpublished_projects(self, project_factory): + project_factory(published=True) + project_factory(published=True) + project_factory(published=False) + + @pytest.mark.parametrize( + "token__user,expected_count", + [ + (LazyFixture("agency_user"), 3), + (LazyFixture("client_user"), 2), + (LazyFixture("supplier_user"), 3), + ], + ) + def test_user_can_only_see_visible_projects( + self, client, request_url, token, expected_count + ): + + # Act + response = client.get( + path=request_url, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(data) == expected_count diff --git a/evg-outbo-api/tests/e2e/projects/project_creation_tests.py b/evg-outbo-api/tests/e2e/projects/project_creation_tests.py new file mode 100755 index 0000000..094ea77 --- /dev/null +++ b/evg-outbo-api/tests/e2e/projects/project_creation_tests.py @@ -0,0 +1,281 @@ +from typing import TYPE_CHECKING + +import pytest +from pytest import fixture +from rest_framework import status +from rest_framework.utils import json + +from outbo.apps.projects.models import Project +from tests import utils + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + from rest_framework.authtoken.models import Token + + +@pytest.mark.django_db +class ProjectCreationE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + BASE_FIELDS = ( + "brand", + "category", + "description", + "name", + "printing_files_date", + "promo_date", + ) + + CREATE_FIELDS = BASE_FIELDS + ("products_amount",) + RETRIEVE_FIELDS = BASE_FIELDS + ("slug", "publishable", "published", "products") + + FIELD_REQUIRED_MESSAGE = "This field is required." + AT_LEAST_ONE_PRODUCT = "Ensure this value is greater than or equal to 1." + + def test_anonymous_user_is_not_authorized(self, client): + response = client.post("/projects/", content_type="application/json") + # Assert + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), "Wrong HTTP response code." + + def test_agency_user_not_allowed_to_create_project( + self, client, agency_user, token_factory, unauthorized_message + ): + token: "Token" = token_factory(user=agency_user) + response = client.post( + path="/projects/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + # Assert + assert ( + response.status_code == status.HTTP_403_FORBIDDEN + ), "Invalid HTTP response code." + assert data["detail"] == unauthorized_message, "Invalid error message." + + def test_client_user_not_allowed_to_create_project( + self, client, client_user, token_factory, unauthorized_message + ): + # Arrange + token: "Token" = token_factory(user=client_user) + + # Act + response = client.post( + path="/projects/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert response.status_code == status.HTTP_403_FORBIDDEN + assert data["detail"] == unauthorized_message + + def test_admin_user_not_allowed_to_create_project( + self, client, user_factory, token_factory, unauthorized_message + ): + # Arrange + user: "User" = user_factory(is_staff=True) + token: "Token" = token_factory(user=user) + # Act + response = client.post( + path="/projects/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + # Assert + assert response.status_code == status.HTTP_403_FORBIDDEN + assert data["detail"] == unauthorized_message + + def test_supplier_user_fails_to_create_project_with_empty_data( + self, client, supplier_user, token_factory, + ): + # Arrange + token: "Token" = token_factory(user=supplier_user) + + # Act + response = client.post( + path="/projects/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert ( + response.status_code == status.HTTP_400_BAD_REQUEST + ), "Invalid HTTP response code." + assert Project.objects.count() == 0, "Product was created and should not be." + for item in self.CREATE_FIELDS: + assert item in data + assert data[item] == [ + self.FIELD_REQUIRED_MESSAGE + ], f"""Invalid error message for field "{item}".""" + + def test_supplier_user_creates_project_with_valid_data( + self, client, supplier_user, category_factory, brand_factory, token_factory, + ): + # Arrange + token: "Token" = token_factory(user=supplier_user) + category = category_factory(name="Sample category") + brand = brand_factory(name="Sample brand") + + project_data = { + "name": "Sample name", + "description": "Sample description", + "products_amount": 1, + "category": category.pk, + "brand": brand.pk, + "printing_files_date": "2019-10-30", + "promo_date": "2020-01-01", + } + + expected_data = { + "name": "Sample name", + "description": "Sample description", + "products": "http://testserver/products/?project=1", + "category": category.pk, + "brand": brand.pk, + "printing_files_date": "2019-10-30", + "promo_date": "2020-01-01", + "slug": "#SASA1-0020", + "publishable": False, + "published": False, + } + + # Act + response = client.post( + path="/projects/", + data=json.dumps(project_data), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert utils.is_subset(subset=expected_data, superset=data) + + @pytest.mark.parametrize("products_amount", [1, 2, 4]) + def test_supplier_user_creates_products_for_project( + self, + client, + supplier_user, + category_factory, + brand_factory, + token_factory, + products_amount, + ): + # Arrange + token: "Token" = token_factory(user=supplier_user) + category = category_factory(name="Sample category") + brand = brand_factory(name="Sample brand") + + project_data = { + "name": "Sample name", + "description": "Sample description", + "products_amount": products_amount, + "category": category.pk, + "brand": brand.pk, + "printing_files_date": "2019-10-30", + "promo_date": "2020-01-01", + } + + # Act + response = client.post( + path="/projects/", + data=json.dumps(project_data), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert Project.objects.count() == 1 + project = Project.objects.first() + + assert project.products.count() == products_amount + + def test_supplier_user_fails_when_zero_products( + self, client, supplier_user, category_factory, brand_factory, token_factory, + ): + # Arrange + token: "Token" = token_factory(user=supplier_user) + category = category_factory(name="Sample category") + brand = brand_factory(name="Sample brand") + + project_data = { + "name": "Sample name", + "description": "Sample description", + "products_amount": 0, + "category": category.pk, + "brand": brand.pk, + "printing_files_date": "2019-10-30", + "promo_date": "2020-01-01", + } + + expected_data = {"products_amount": [self.AT_LEAST_ONE_PRODUCT]} + + # Act + response = client.post( + path="/projects/", + data=json.dumps(project_data), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert ( + response.status_code == status.HTTP_400_BAD_REQUEST + ), "Invalid HTTP response code." + assert data == expected_data, "Error message is invalid." + assert Project.objects.count() == 0, "Product was created and should not be." + + @pytest.mark.parametrize("products_amount", [1, 2, 4]) + def test_supplier_user_products_in_project_have_valid_stage( + self, + client, + supplier_user, + category_factory, + brand_factory, + token_factory, + products_amount, + ): + # Arrange + token: "Token" = token_factory(user=supplier_user) + category = category_factory(name="Sample category") + brand = brand_factory(name="Sample brand") + + project_data = { + "name": "Sample name", + "description": "Sample description", + "products_amount": products_amount, + "category": category.pk, + "brand": brand.pk, + "printing_files_date": "2019-10-30", + "promo_date": "2020-01-01", + } + + # Act + response = client.post( + path="/projects/", + data=json.dumps(project_data), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert Project.objects.count() == 1 + project = Project.objects.first() + + for product in project.products.all(): + assert ( + product.stage_id == "waiting_for_brief" + ), f"""Product "{product}" has invalid stage.""" diff --git a/evg-outbo-api/tests/e2e/projects/project_publishing_e2e_tests.py b/evg-outbo-api/tests/e2e/projects/project_publishing_e2e_tests.py new file mode 100755 index 0000000..14175b2 --- /dev/null +++ b/evg-outbo-api/tests/e2e/projects/project_publishing_e2e_tests.py @@ -0,0 +1,87 @@ +from typing import TYPE_CHECKING + +import pytest +from pytest import fixture +from rest_framework import status + + +if TYPE_CHECKING: + from outbo.apps.authentication.models import User + from rest_framework.authtoken.models import Token + + +@pytest.mark.django_db +class ProjectPublishingE2ETests: + @fixture(autouse=True) + def product_stages(self, product_stages): + pass + + @fixture(autouse=True) + def project_with_two_products(self, project, product_factory): + product_factory(project=project) + product_factory(project=project) + + def test_anonymous_user_is_not_authorized(self, client, project): + response = client.patch( + f"/projects/{project.pk}/", content_type="application/json" + ) + + # Assert + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), "Wrong HTTP response code." + + def test_agency_user_not_allowed_to_publish_project( + self, client, agency_user, token_factory, project, unauthorized_message + ): + # Arrange + token: "Token" = token_factory(user=agency_user) + + # Act + response = client.patch( + path=f"/projects/{project.pk}/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert ( + response.status_code == status.HTTP_403_FORBIDDEN + ), "Invalid HTTP response code." + assert data["detail"] == unauthorized_message, "Invalid error message." + + def test_client_user_not_allowed_to_publish_project( + self, client, client_user, token_factory, project, unauthorized_message + ): + # Arrange + token: "Token" = token_factory(user=client_user) + + # Act + response = client.patch( + path=f"/projects/{project.pk}/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + + # Assert + assert response.status_code == status.HTTP_403_FORBIDDEN + assert data["detail"] == unauthorized_message + + def test_admin_user_not_allowed_to_publish_project( + self, client, user_factory, token_factory, project, unauthorized_message + ): + # Arrange + user: "User" = user_factory(is_staff=True) + token: "Token" = token_factory(user=user) + # Act + response = client.patch( + path=f"/projects/{project.pk}/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + data = response.json() + # Assert + assert response.status_code == status.HTTP_403_FORBIDDEN + assert data["detail"] == unauthorized_message diff --git a/evg-outbo-api/tests/factories/authentication.py b/evg-outbo-api/tests/factories/authentication.py new file mode 100755 index 0000000..4b5b8df --- /dev/null +++ b/evg-outbo-api/tests/factories/authentication.py @@ -0,0 +1,42 @@ +import factory +from faker import Factory as FakerFactory + +from outbo.apps.authentication import models +from tests.factories.organizations import ( + AgencyOrganizationFactory, + ClientOrganizationFactory, + SupplierOrganizationFactory, +) + +faker = FakerFactory.create() + + +class UserFactory(factory.django.DjangoModelFactory): + username = factory.LazyAttribute(lambda x: faker.profile()["username"]) + first_name = factory.LazyAttribute(lambda x: faker.first_name()) + last_name = factory.LazyAttribute(lambda x: faker.last_name()) + email = factory.LazyAttribute(lambda x: faker.profile()["mail"]) + is_staff = False + is_superuser = False + password = "password" + + @classmethod + def _create(cls, model_class, *args, **kwargs): + manager = cls._get_manager(model_class) + return manager.create_user(*args, **kwargs) + + class Meta: + model = models.User + django_get_or_create = ("username",) + + +class AgencyUserFactory(UserFactory): + organization = factory.SubFactory(AgencyOrganizationFactory) + + +class ClientUserFactory(UserFactory): + organization = factory.SubFactory(ClientOrganizationFactory) + + +class SupplierUserFactory(UserFactory): + organization = factory.SubFactory(SupplierOrganizationFactory) diff --git a/evg-outbo-api/tests/factories/organizations.py b/evg-outbo-api/tests/factories/organizations.py new file mode 100755 index 0000000..bd9fbd2 --- /dev/null +++ b/evg-outbo-api/tests/factories/organizations.py @@ -0,0 +1,42 @@ +import factory +from faker import Factory as FakerFactory + +from outbo.apps.organizations import models + +faker = FakerFactory.create() + + +class AgencyOrganizationFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.company()) + + class Meta: + model = models.AgencyOrganization + + +class ClientOrganizationFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.company()) + + class Meta: + model = models.ClientOrganization + + +class SupplierOrganizationFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.company()) + + class Meta: + model = models.SupplierOrganization + + +class CategoryFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.word()) + + class Meta: + model = models.Category + + +class BrandFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.word()) + client = factory.SubFactory(ClientOrganizationFactory) + + class Meta: + model = models.Brand diff --git a/evg-outbo-api/tests/factories/products.py b/evg-outbo-api/tests/factories/products.py new file mode 100755 index 0000000..8dcfe3f --- /dev/null +++ b/evg-outbo-api/tests/factories/products.py @@ -0,0 +1,33 @@ +import factory + +from outbo.apps.products import models +from tests.factories.projects import ProjectFactory +from faker import Factory as FakerFactory + +faker = FakerFactory.create() + + +class ProductStageFactory(factory.django.DjangoModelFactory): + id = "" + label = "" + group = 0 + description = "" + next_action = "" + order = 0 + + class Meta: + model = models.ProductStage + + +class PackagingTypeFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.word()) + + class Meta: + model = models.PackagingType + + +class ProductFactory(factory.django.DjangoModelFactory): + project = factory.SubFactory(ProjectFactory) + + class Meta: + model = models.Product diff --git a/evg-outbo-api/tests/factories/projects.py b/evg-outbo-api/tests/factories/projects.py new file mode 100755 index 0000000..74dbf12 --- /dev/null +++ b/evg-outbo-api/tests/factories/projects.py @@ -0,0 +1,30 @@ +import factory +from faker import Factory as FakerFactory + +from outbo.apps.projects import models +from tests.factories.organizations import CategoryFactory, BrandFactory + +faker = FakerFactory.create() + + +class ProjectFactory(factory.django.DjangoModelFactory): + name = factory.LazyAttribute(lambda x: faker.sentence()) + description = factory.LazyAttribute(lambda x: " ".join(faker.sentences(nb=3))) + category = factory.SubFactory(CategoryFactory) + brand = factory.SubFactory(BrandFactory) + printing_files_date = factory.LazyAttribute( + lambda x: faker.date_between(start_date="today", end_date="+60d") + ) + promo_date = factory.LazyAttribute( + lambda x: faker.date_between(start_date="+60d", end_date="+120d") + ) + products_amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10)) + published = False + + @classmethod + def _create(cls, model_class, *args, **kwargs): + manager = cls._get_manager(model_class) + return manager.create(*args, **kwargs) + + class Meta: + model = models.Project diff --git a/evg-outbo-api/tests/factories/token.py b/evg-outbo-api/tests/factories/token.py new file mode 100755 index 0000000..f177d3d --- /dev/null +++ b/evg-outbo-api/tests/factories/token.py @@ -0,0 +1,18 @@ +import binascii +import os + +import factory +from faker import Factory as FakerFactory +from rest_framework.authtoken import models + +from tests.factories.authentication import UserFactory + +faker = FakerFactory.create() + + +class TokenFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + key = factory.LazyAttribute(lambda x: binascii.hexlify(os.urandom(20)).decode()) + + class Meta: + model = models.Token diff --git a/evg-outbo-api/tests/models/authentication/user_model_tests.py b/evg-outbo-api/tests/models/authentication/user_model_tests.py new file mode 100755 index 0000000..e09eb82 --- /dev/null +++ b/evg-outbo-api/tests/models/authentication/user_model_tests.py @@ -0,0 +1,41 @@ +import pytest + + +@pytest.mark.django_db +def test_user_has_proper_username(user_factory): + user = user_factory(username="username") + assert user.username == "username" + + +@pytest.mark.django_db +def test_user_is_admin(user_factory): + admin_user = user_factory(is_staff=True) + + assert admin_user.is_staff is True + assert admin_user.is_agency is False + assert admin_user.is_client is False + assert admin_user.is_supplier is False + + +@pytest.mark.django_db +def test_user_has_agency(agency_user): + assert agency_user.is_staff is False + assert agency_user.is_agency is True + assert agency_user.is_client is False + assert agency_user.is_supplier is False + + +@pytest.mark.django_db +def test_user_has_client(client_user): + assert client_user.is_staff is False + assert client_user.is_agency is False + assert client_user.is_client is True + assert client_user.is_supplier is False + + +@pytest.mark.django_db +def test_user_has_supplier(supplier_user): + assert supplier_user.is_staff is False + assert supplier_user.is_agency is False + assert supplier_user.is_client is False + assert supplier_user.is_supplier is True diff --git a/evg-outbo-api/tests/tests.py b/evg-outbo-api/tests/tests.py new file mode 100755 index 0000000..04b14fc --- /dev/null +++ b/evg-outbo-api/tests/tests.py @@ -0,0 +1,2 @@ +def test_pytest_running(): + assert True diff --git a/evg-outbo-api/tests/utils.py b/evg-outbo-api/tests/utils.py new file mode 100755 index 0000000..596d5ae --- /dev/null +++ b/evg-outbo-api/tests/utils.py @@ -0,0 +1,25 @@ +def is_subset(*, superset, subset): + for key, value in subset.items(): + if key not in superset: + return False + + if isinstance(value, dict): + if not is_subset(superset=superset[key], subset=value): + return False + + elif isinstance(value, str): + if value not in superset[key]: + return False + + elif isinstance(value, list): + if not set(value) <= set(superset[key]): + return False + elif isinstance(value, set): + if not value <= superset[key]: + return False + + else: + if not value == superset[key]: + return False + + return True diff --git a/evg-outbo-web/.babelrc b/evg-outbo-web/.babelrc new file mode 100755 index 0000000..2e056bc --- /dev/null +++ b/evg-outbo-web/.babelrc @@ -0,0 +1,10 @@ +{ + "plugins": [ + [ + "@babel/plugin-proposal-optional-chaining" + ], + [ + "add-react-displayname" + ] + ] +} \ No newline at end of file diff --git a/evg-outbo-web/.eslintrc b/evg-outbo-web/.eslintrc new file mode 100755 index 0000000..fd73454 --- /dev/null +++ b/evg-outbo-web/.eslintrc @@ -0,0 +1,44 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.eslint.json", + "tsconfigRootDir": "./", + "ecmaFeatures": { + "jsx": true + } + }, + "plugins": ["@typescript-eslint", "prettier"], + "extends": [ + "prettier", + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + ], + "rules": { + "no-nested-ternary": 0, + "no-console": 0, + "react/prop-types": 0, + "react/jsx-filename-extension": [ + 1, + { + "extensions": [".js", ".jsx", ".tsx", ".ts"] + } + ], + "prettier/prettier": [ + "error", + { + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120 + } + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/evg-outbo-web/.gitignore b/evg-outbo-web/.gitignore new file mode 100755 index 0000000..ca54055 --- /dev/null +++ b/evg-outbo-web/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +.vscode/* +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# JetBrains +.idea \ No newline at end of file diff --git a/evg-outbo-web/.prettierrc b/evg-outbo-web/.prettierrc new file mode 100755 index 0000000..eec7a84 --- /dev/null +++ b/evg-outbo-web/.prettierrc @@ -0,0 +1,13 @@ +{ + "printWidth": 120, + "singleQuote": true, + "trailingComma": "es5", + "useTabs": false, + "tabWidth": 2, + "jsxSingleQuote": false, + "jsxBracketSameLine": false, + "bracketSpacing": true, + "arrowParens": "always", + "parser": "typescript", + "endOfLine": "lf" +} diff --git a/evg-outbo-web/README.md b/evg-outbo-web/README.md new file mode 100755 index 0000000..897dc83 --- /dev/null +++ b/evg-outbo-web/README.md @@ -0,0 +1,44 @@ +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.
+See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/evg-outbo-web/config-overrides.js b/evg-outbo-web/config-overrides.js new file mode 100755 index 0000000..acff149 --- /dev/null +++ b/evg-outbo-web/config-overrides.js @@ -0,0 +1,2 @@ +const { useBabelRc, override, useEslintRc } = require('customize-cra'); +module.exports = override(useBabelRc()); diff --git a/evg-outbo-web/index.d.ts b/evg-outbo-web/index.d.ts new file mode 100755 index 0000000..a943d04 --- /dev/null +++ b/evg-outbo-web/index.d.ts @@ -0,0 +1,10 @@ +declare module '*.svg' { + const value: string; + export default value; + + function createRef(): RefObject; + interface RefObject { + // immutable + readonly current: T | null; + } +} diff --git a/evg-outbo-web/jest.config.js b/evg-outbo-web/jest.config.js new file mode 100755 index 0000000..6debbfd --- /dev/null +++ b/evg-outbo-web/jest.config.js @@ -0,0 +1,31 @@ +module.exports = { + // The root of your source code, typically /src + // `` is a token Jest substitutes + roots: ['/src'], + modulePaths: ['/src/'], + // Jest transformations -- this adds support for TypeScript + // using ts-jest + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + + // Runs special logic, such as cleaning up components + // when using React Testing Library and adds special + // extended assertions to Jest + setupFilesAfterEnv: ['@testing-library/react/cleanup-after-each', '@testing-library/jest-dom/extend-expect'], + + // Test spec file resolution pattern + // Matches parent folder `__tests__` and filename + // should contain `test` or `spec`. + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', + modulePathIgnorePatterns: ['node_modules'], + // Module file extensions for importing + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleNameMapper: { + '\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/__mocks__/fileMock.js', + '\\.(css|less)$': '/mocks/fileMock.js', + }, + collectCoverage: true, + mapCoverage: true, +}; diff --git a/evg-outbo-web/package.json b/evg-outbo-web/package.json new file mode 100755 index 0000000..f5e48a2 --- /dev/null +++ b/evg-outbo-web/package.json @@ -0,0 +1,103 @@ +{ + "name": "evg-box-web", + "version": "0.1.0", + "private": true, + "dependencies": { + "@react-pdf/renderer": "^2.0.13", + "@sentry/browser": "^5.25.0", + "@sentry/react": "^5.25.0", + "@testing-library/jest-dom": "^4.2.4", + "@types/flat": "^0.0.28", + "@types/jest": "^24.0.23", + "@types/lodash.debounce": "^4.0.6", + "@types/lodash.isequal": "^4.5.5", + "@types/lodash.max": "^4.0.6", + "@types/logrocket-react": "^3.0.0", + "@types/node": "12.7.4", + "@types/react": "^16.9.2", + "@types/react-datepicker": "^2.9.2", + "@types/react-dom": "16.9.0", + "@types/react-redux": "^7.1.2", + "@types/react-router-dom": "^5.1.2", + "@types/redux-mock-store": "^1.0.1", + "@types/yup": "^0.26.24", + "axios": "^0.19.0", + "axios-retry": "^3.1.2", + "babel-plugin-add-react-displayname": "^0.0.5", + "babel-plugin-react-remove-properties": "^0.3.0", + "bulma": "^0.7.5", + "bulma-steps-component": "^0.5.3", + "bulma-tooltip": "^2.0.2", + "classnames": "^2.2.6", + "date-fns": "^2.4.1", + "filesize": "^5.0.1", + "flat": "^5.0.0", + "formik": "^2.0.3", + "immer": "^5.0.0", + "jest": "^24.9.0", + "less": "^3.10.3", + "lodash.debounce": "4.0.8", + "lodash.isequal": "^4.5.0", + "lodash.max": "^4.0.1", + "logrocket": "^1.0.14", + "logrocket-react": "4", + "node-sass": "^4.12.0", + "polish-plurals": "^1.1.0", + "query-string": "^6.10.1", + "react": "^16.14.0", + "react-datepicker": "^2.9.6", + "react-dom": "^16.9.0", + "react-image-lightbox": "^5.1.0", + "react-redux": "^7.1.1", + "react-router-dom": "^5.1.2", + "react-scripts": "3.1.2", + "react-spinners": "^0.6.1", + "react-toastify": "^5.4.1", + "react-use": "^15.0.2", + "redux": "^4.0.4", + "redux-devtools-extension": "^2.13.8", + "redux-mock-store": "^1.5.3", + "reselect": "^4.0.0", + "ts-jest": "^24.2.0", + "typesafe-actions": "^5.1.0", + "typescript": "3.7.2", + "use-onclickoutside": "^0.3.1", + "yup": "^0.27.0" + }, + "scripts": { + "start": "react-app-rewired start", + "build": "react-app-rewired build", + "test": "react-app-rewired test --env=jsdom", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@babel/plugin-proposal-optional-chaining": "^7.6.0", + "@testing-library/react": "^9.3.2", + "@types/classnames": "^2.2.9", + "@typescript-eslint/eslint-plugin": "^2.7.0", + "@typescript-eslint/parser": "^2.7.0", + "customize-cra": "^0.9.1", + "eslint": "^6.4.0", + "eslint-config-prettier": "^6.2.0", + "eslint-plugin-prettier": "^3.1.0", + "eslint-plugin-react": "^7.14.3", + "eslint-plugin-react-hooks": "^1.6.1", + "prettier": "^1.18.2", + "react-app-rewired": "^2.1.5" + } +} diff --git a/evg-outbo-web/public/favicon.ico b/evg-outbo-web/public/favicon.ico new file mode 100755 index 0000000..f92ae67 Binary files /dev/null and b/evg-outbo-web/public/favicon.ico differ diff --git a/evg-outbo-web/public/index.html b/evg-outbo-web/public/index.html new file mode 100755 index 0000000..52788ac --- /dev/null +++ b/evg-outbo-web/public/index.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + Outbo + + + +
+ + + + diff --git a/evg-outbo-web/public/manifest.json b/evg-outbo-web/public/manifest.json new file mode 100755 index 0000000..d1c1d1d --- /dev/null +++ b/evg-outbo-web/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/evg-outbo-web/public/robots.txt b/evg-outbo-web/public/robots.txt new file mode 100755 index 0000000..01b0f9a --- /dev/null +++ b/evg-outbo-web/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/evg-outbo-web/src/API/axiosConfig.ts b/evg-outbo-web/src/API/axiosConfig.ts new file mode 100755 index 0000000..16e1198 --- /dev/null +++ b/evg-outbo-web/src/API/axiosConfig.ts @@ -0,0 +1,115 @@ +import axios, { AxiosError } from 'axios'; +import { history } from '../App'; +import { toast } from 'react-toastify'; +import store from 'store/store'; +import { setProgress, clearProgress } from 'modules/progress/actions'; + +export const TOKEN = 'outbo-token-key'; +let baseURL = process.env.REACT_APP_BACKEND_URL; + +if (!baseURL) { + // throw new Error('API URL not set') + // baseURL = 'https://api.outbo.pl/'; + baseURL = 'https://outbo-api.stage.ermlab.com'; + // baseURL = 'https://f101aae2d6a5.ngrok.io/'; +} +const api = axios.create({ + baseURL, + timeout: 60000, + validateStatus: (status) => status >= 200 && status < 300, +}); + +api.defaults.transformResponse = [ + function transformResponse(data) { + /*eslint no-param-reassign:0*/ + const ISO_8601 = /(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}.\d{3,6})[+-]\d{2}:\d{2}/; + const reviver = (key: string, value: any) => { + if (typeof value === 'string' && ISO_8601.test(value)) { + return new Date(value); + } + return value; + }; + if (typeof data === 'string') { + try { + data = JSON.parse(data, reviver); + } catch (e) { + /* Ignore */ + } + } + return data; + }, +]; + +api.interceptors.request.use( + (config) => { + if (localStorage.getItem(TOKEN)) { + config.headers['Authorization'] = `Bearer ${localStorage.getItem(TOKEN)}`; + } + return config; + }, + async (error) => Promise.reject(error) +); + +api.interceptors.request.use( + (config) => { + // Do something before request is sent + store.dispatch(setProgress(0)); + return config; + }, + async (error: AxiosError) => Promise.reject(error) +); + +api.interceptors.response.use( + (response) => response, + + async (error: AxiosError) => { + if (error.response && [401].includes(error.response.status)) { + toast.error('Zaloguj się ponownie.'); + localStorage.removeItem(TOKEN); + history.push('/login'); + } + return Promise.reject(error); + } +); + +//PROGRSS BAR +const calculatePercentage = (loaded: any, total: any) => Math.floor(loaded * 1.0) / total; + +api.defaults.onUploadProgress = (e) => { + const percentage = calculatePercentage(e.loaded, e.total); + store.dispatch(setProgress(percentage)); +}; +api.interceptors.response.use( + (response) => { + store.dispatch(clearProgress()); + return response; + }, + async (error: AxiosError) => { + store.dispatch(clearProgress()); + return Promise.reject(error); + } +); + +function retryFailedRequest(err: any) { + if (err.status === 500 && err.config && !err.config.__isRetryRequest) { + err.config.__isRetryRequest = true; + return axios(err.config); + } + throw err; +} +axios.interceptors.response.use(undefined, retryFailedRequest); + +// axios.interceptors.response.use(null, (error) => { +// if (error.config && error.response && error.response.status === 401) { +// return updateToken().then((token) => { +// error.config.headers.xxxx <= set the token +// return axios.request(error.onfig); +// }); +// } + +// return Promise.reject(error); +// }); + +// axiosRetry(api, { retries: 3 }); + +export default api; diff --git a/evg-outbo-web/src/API/briefs.ts b/evg-outbo-web/src/API/briefs.ts new file mode 100755 index 0000000..002658c --- /dev/null +++ b/evg-outbo-web/src/API/briefs.ts @@ -0,0 +1,116 @@ +import API from './axiosConfig'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; +import { BriefRequest, BriefResponse } from 'interfaces/API/products'; +import { ReactText } from 'react'; + +const getBriefById = async (briefId: number): Promise => { + try { + const response = await API.get(`/briefs/${briefId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const patchBriefById = async (briefId: number, data: BriefRequest): Promise => { + try { + const { packagingType, ...rest } = data; + let formData; + if (packagingType === null) { + formData = objectToFormDataConverter(rest); + } else { + formData = objectToFormDataConverter(data); + } + const response = await API.patch(`/briefs/${briefId}/`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + return null; + } +}; + +const acceptBrief = async (briefId: number): Promise => { + try { + const response = await API.patch(`/briefs/${briefId}/`, { accept: true }); + return response.data; + } catch (err) { + return null; + } +}; + +const rejectBrief = async (briefId: number): Promise => { + try { + const response = await API.patch(`/briefs/${briefId}/`, { accept: false }); + return response.data; + } catch (err) { + return null; + } +}; + +const publishBrief = async (briefId: number): Promise => { + try { + const response = await API.patch(`/briefs/${briefId}/`, { publish: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const deleteBlanking = async (blankingId: number): Promise => { + try { + const response = await API.delete(`/blankings/${blankingId}/`); + return response.status; + } catch (err) { + console.error(err); + return null; + } +}; + +const deletePhoto = async (photoId: number): Promise => { + try { + const response = await API.delete(`/photos/${photoId}/`); + return response.status; + } catch (err) { + console.error(err); + return null; + } +}; +const cloneBrief = async (copyFrom: number, briefId: number): Promise => { + try { + const response = await API.patch(`/briefs/${briefId}/`, { cloneFrom: copyFrom }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const exportBriefToPdf = async (briefId: ReactText, filename: string): Promise => { + try { + const response = await API.get(`/briefs/${briefId}/export-to-pdf/`, { responseType: 'blob' }); + const file = new Blob([response.data], { type: 'application/pdf' }); + const fileURL = URL.createObjectURL(file); + const link = document.createElement('a'); + link.href = fileURL; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + } catch (err) { + return null; + } +}; + +export default { + exportBriefToPdf, + cloneBrief, + deleteBlanking, + deletePhoto, + getBriefById, + acceptBrief, + rejectBrief, + patchBriefById, + publishBrief, +}; diff --git a/evg-outbo-web/src/API/drafts.ts b/evg-outbo-web/src/API/drafts.ts new file mode 100755 index 0000000..f444aea --- /dev/null +++ b/evg-outbo-web/src/API/drafts.ts @@ -0,0 +1,57 @@ +import API from './axiosConfig'; +import { DesignStageResponse, DesignStageFileRequest, DesignStageFileResponse } from 'interfaces/API/draft'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; +import { AxiosResponse } from 'axios'; + +const getDraftById = async (draftId: number): Promise => { + try { + const response = await API.get(`/drafts/${draftId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const addDraftFile = async (draftId: number, data: DesignStageFileRequest): Promise => { + try { + const formData = objectToFormDataConverter(data); + const response = await API.post(`/drafts/${draftId}/files/`, formData); + return response.data; + } catch (err) { + return null; + } +}; + +const acceptDraft = async (draftId: number): Promise => { + try { + const response = await API.patch(`/drafts/${draftId}/`, { accept: true }); + return response.data; + } catch (err) { + return null; + } +}; + +const openDraft = async (draftId: number) => { + try { + const response = await API.get(`/draft-files/${draftId}/open-file/`); + return response.data; + } catch (err) { + return null; + } +}; + +export default { + openDraft, + getDraftById, + addDraftFile, + acceptDraft, +}; +// export const fetchDrafts = async (productId: number): Promise => { +// try { +// const response = await API.get(''); +// return response.data; +// } catch (err) { +// console.error(err); +// return []; +// } +// }; diff --git a/evg-outbo-web/src/API/files.ts b/evg-outbo-web/src/API/files.ts new file mode 100755 index 0000000..ba84ba0 --- /dev/null +++ b/evg-outbo-web/src/API/files.ts @@ -0,0 +1,10 @@ +import API from './axiosConfig'; + +export const handlePDFdownload = async (url: string, filename: string): Promise => { + const response = await API({ url, method: 'GET', responseType: 'blob' }); + window.open(response.config.url, '_blank'); + // if (response) { + // const blobUrl = URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + // window.open(blobUrl); + // } +}; diff --git a/evg-outbo-web/src/API/finalPhotoBrief.ts b/evg-outbo-web/src/API/finalPhotoBrief.ts new file mode 100755 index 0000000..e0ad651 --- /dev/null +++ b/evg-outbo-web/src/API/finalPhotoBrief.ts @@ -0,0 +1,47 @@ +import API from './axiosConfig'; +import { + FinalPhotoBriefResponse, + FinalPhotoBriefRequest, + FinalPhotoBriefPhotoType, +} from 'interfaces/API/finalPhotoBrief'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; +import { FinalPhotoBrief } from 'organisms/FinalPhotoBrief/FinalPhotoBrief'; + +const getFinalPhotoBriefById = async (finalPhotoBriefId: number): Promise => { + try { + const response = await API.get(`/final-photobriefing/${finalPhotoBriefId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const removeFinalPhotoBriefPhoto = async (url: string): Promise => { + try { + const response = await API.delete(url); + return response.status; + } catch (err) { + return null; + } +}; + +const addFinalPhotoBriefItems = async ( + data: FinalPhotoBriefRequest, + finalPhotoBriefId: number +): Promise => { + try { + const formData = objectToFormDataConverter(data); + const response = await API.patch(`/final-photobriefing/${finalPhotoBriefId}/`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + return null; + } +}; + +export default { + removeFinalPhotoBriefPhoto, + getFinalPhotoBriefById, + addFinalPhotoBriefItems, +}; diff --git a/evg-outbo-web/src/API/firstFinal.ts b/evg-outbo-web/src/API/firstFinal.ts new file mode 100755 index 0000000..cba5ed7 --- /dev/null +++ b/evg-outbo-web/src/API/firstFinal.ts @@ -0,0 +1,60 @@ +import API from './axiosConfig'; +import { DesignStageResponse, DesignStageFileRequest, DesignStageFileResponse } from 'interfaces/API/draft'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; +import { AxiosResponse } from 'axios'; + +const getFirstFinalById = async (firstFinalId: number): Promise => { + try { + const response = await API.get(`/ffs/${firstFinalId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const addFirstFinalFile = async ( + firstFinalId: number, + data: DesignStageFileRequest +): Promise => { + try { + const formData = objectToFormDataConverter(data); + const response = await API.post(`/ffs/${firstFinalId}/files/`, formData); + return response.data; + } catch (err) { + return null; + } +}; + +const acceptFirstFinalFile = async (firstFinalId: number): Promise => { + try { + const response = await API.patch(`/ffs/${firstFinalId}/`, { accept: true }); + return response.data; + } catch (err) { + return null; + } +}; + +const openFirstFinal = async (draftId: number) => { + try { + const response = await API.get(`/first-final-files/${draftId}/open-file/`); + return response.data; + } catch (err) { + return null; + } +}; + +export default { + openFirstFinal, + getFirstFinalById, + addFirstFinalFile, + acceptFirstFinalFile, +}; +// export const fetchDrafts = async (productId: number): Promise => { +// try { +// const response = await API.get(''); +// return response.data; +// } catch (err) { +// console.error(err); +// return []; +// } +// }; diff --git a/evg-outbo-web/src/API/messages.ts b/evg-outbo-web/src/API/messages.ts new file mode 100755 index 0000000..9a4eee5 --- /dev/null +++ b/evg-outbo-web/src/API/messages.ts @@ -0,0 +1,61 @@ +import API from './axiosConfig'; + +export enum MessageType { + USER = 'USER', + SYSTEM_SUCCESS = 'Success', + SYSTEM_FAILURE = 'Failure', +} + +export type MessageActivityLogItem = { + messageType: MessageType; + message: string; + stage: string; + createdBy: number; + createdAt: Date; +}; + +export const fetchProductActivityLog = async (productId: number): Promise => { + try { + const response = await API.get(`/products/${productId}/messages/`); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +export const fetchProjectActivityLog = async (projectId: number): Promise => { + try { + const response = await API.get(`/projects/${projectId}/messages/`); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +export const postNewProductMessage = async ( + message: string, + productId: number +): Promise => { + try { + const response = await API.post(`/products/${productId}/messages/`, { message }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postNewProjectMessage = async ( + message: string, + projectId: number +): Promise => { + try { + const response = await API.post(`/projects/${projectId}/messages/`, { message }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; diff --git a/evg-outbo-web/src/API/photoBrief.ts b/evg-outbo-web/src/API/photoBrief.ts new file mode 100755 index 0000000..88a5634 --- /dev/null +++ b/evg-outbo-web/src/API/photoBrief.ts @@ -0,0 +1,98 @@ +import API from './axiosConfig'; +import { + PhotoBriefResponse, + PhotoBriefCategoryResponse, + PhotoBriefItemRequest, + PhotoBriefItemResponse, + PhotoBriefChoiceRequest, + PhotoBriefChoiceResponse, +} from 'interfaces/API/photoBrief'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; + +const getPhotoBriefById = async (photoBriefId: number): Promise => { + try { + const response = await API.get(`/photobriefing/${photoBriefId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const getPhotoBriefCategories = async (): Promise => { + try { + const response = await API.get('/photobriefing-categories/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +const addPhotoBriefItem = async ( + data: PhotoBriefItemRequest, + photoBriefId: number +): Promise => { + try { + const formData = objectToFormDataConverter(data); + const response = await API.post(`/photobriefing/${photoBriefId}/items/`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const acceptPhotoBriefItem = async ( + data: PhotoBriefChoiceRequest, + photoBriefId: number +): Promise => { + try { + const response = await API.patch(`/photobriefing/${photoBriefId}/`, { choice: data }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const publishPhotoBrief = async (photoBriefId: number): Promise => { + try { + const response = await API.patch(`/photobriefing/${photoBriefId}/`, { publish: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const deletePhotoBriefItem = async (itemId: number): Promise => { + try { + const response = await API.delete(`/photobriefing-items/${itemId}/`); + return response.status; + } catch (err) { + console.error(err); + return null; + } +}; + +const finishPhotoBrief = async (photoBriefId: number): Promise => { + try { + const response = await API.patch(`/photobriefing/${photoBriefId}/`, { complete: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export default { + finishPhotoBrief, + deletePhotoBriefItem, + publishPhotoBrief, + acceptPhotoBriefItem, + addPhotoBriefItem, + getPhotoBriefById, + getPhotoBriefCategories, +}; diff --git a/evg-outbo-web/src/API/printingFiles.ts b/evg-outbo-web/src/API/printingFiles.ts new file mode 100755 index 0000000..1a53ed4 --- /dev/null +++ b/evg-outbo-web/src/API/printingFiles.ts @@ -0,0 +1,60 @@ +import API from './axiosConfig'; +import { DesignStageResponse, DesignStageFileRequest, DesignStageFileResponse } from 'interfaces/API/draft'; +import objectToFormDataConverter from 'helpers/objectToFormDataConverter'; +import { AxiosResponse } from 'axios'; + +const getPrintingFileById = async (printingFileId: number): Promise => { + try { + const response = await API.get(`/pfs/${printingFileId}/`); + return response.data; + } catch (err) { + return null; + } +}; + +const addPrintingFilesFile = async ( + printingFileId: number, + data: DesignStageFileRequest +): Promise => { + try { + const formData = objectToFormDataConverter(data); + const response = await API.post(`/pfs/${printingFileId}/files/`, formData); + return response.data; + } catch (err) { + return null; + } +}; + +const acceptPrintingFilesFile = async (printingFileId: number): Promise => { + try { + const response = await API.patch(`/pfs/${printingFileId}/`, { accept: true }); + return response.data; + } catch (err) { + return null; + } +}; + +const openPrintingFile = async (pringintFileId: number) => { + try { + const response = await API.get(`/printing-files-files/${pringintFileId}/open-file/`); + return response.data; + } catch (err) { + return null; + } +}; + +export default { + openPrintingFile, + getPrintingFileById, + addPrintingFilesFile, + acceptPrintingFilesFile, +}; +// export const fetchDrafts = async (productId: number): Promise => { +// try { +// const response = await API.get(''); +// return response.data; +// } catch (err) { +// console.error(err); +// return []; +// } +// }; diff --git a/evg-outbo-web/src/API/products.ts b/evg-outbo-web/src/API/products.ts new file mode 100755 index 0000000..3376b05 --- /dev/null +++ b/evg-outbo-web/src/API/products.ts @@ -0,0 +1,49 @@ +import API from './axiosConfig'; +import { ProductResponse, SimpleProductResponse, AnyProductStore } from 'interfaces/API/products'; + +export enum MessageType { + USER = 'USER', + SYSTEM_SUCCESS = 'SYSTEM_SUCCESS', + SYSTEM_FAILURE = 'SYSTEM_FAILURE', +} + +export type ProductActivityLogItem = { + messageType: MessageType; + message: string; + stage: string; + createdBy: number; + createdAt: Date; +}; + +const getProducts = async (): Promise => { + try { + const response = await API.get('/products/'); + return response.data; + } catch (err) { + return null; + } +}; +//TODO: USED?? +const getProductById = async (id: number): Promise => { + try { + const response = await API.get(`/products/${id}/`); + return response.data; + } catch (err) { + return null; + } +}; +//TODO: USED?? +const getProductsByURL = async (url: string): Promise => { + try { + const response = await API.get(url); + return response.data; + } catch (err) { + return null; + } +}; + +export default { + getProductById, + getProducts, + getProductsByURL, +}; diff --git a/evg-outbo-web/src/API/projectData.ts b/evg-outbo-web/src/API/projectData.ts new file mode 100755 index 0000000..b2754d9 --- /dev/null +++ b/evg-outbo-web/src/API/projectData.ts @@ -0,0 +1,75 @@ +import API from './axiosConfig'; +import { ProductStage, ProductStageGroup } from 'interfaces/API/projects'; + +export interface ProductStageResponse { + id: ProductStage; + label: string; + group: ProductStageGroup; + description: string; +} + +export interface DropdownItem { + id: number; + name: string; +} + +export type PackagingType = { + value: number; + label: string; +}; + +export type Brand = { + id: number; + name: string; +}; + +export type Category = { + id: number; + name: string; +}; + +const fetchCategories = async (): Promise => { + try { + const response = await API.get('/categories/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +const fetchBrands = async (): Promise => { + try { + const response = await API.get('/brands/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +const fetchPackagingTypes = async (): Promise => { + try { + const response = await API.get('/packaging-types/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; +const fetchProductStages = async (): Promise => { + try { + const response = await API.get('/stages/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +export default { + fetchCategories, + fetchBrands, + fetchPackagingTypes, + fetchProductStages, +}; diff --git a/evg-outbo-web/src/API/projects.ts b/evg-outbo-web/src/API/projects.ts new file mode 100755 index 0000000..e545a29 --- /dev/null +++ b/evg-outbo-web/src/API/projects.ts @@ -0,0 +1,56 @@ +import API from './axiosConfig'; +import { ProjectResponse, ProjectRequest, SimpleProject } from 'interfaces/API/projects'; +import { toast } from 'react-toastify'; +import { ProjectsQueryParams } from '../interfaces/API/projects'; + +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +const getProjects = async (params: RequireAtLeastOne): Promise => { + try { + const response = await API.get('/projects/', { params }); + return response.data; + } catch (err) { + toast.error('Nie udało się pobrać listy projektów.'); + return null; + } +}; + +const getProjectById = async (projectId: number): Promise => { + try { + const response = await API.get(`projects/${projectId}/`); + return response.data; + } catch (err) { + toast.error('Wystąpił problem podczas pobierania projektu.'); + return null; + } +}; + +const createNewProject = async (data: ProjectRequest): Promise => { + try { + const response = await API.post('/projects/', data); + return response.data; + } catch (err) { + toast.error('Wystąpił problem podczas tworzenia projektu.'); + return null; + } +}; + +const publishProject = async (projectId: number): Promise => { + try { + const response = await API.patch(`/projects/${projectId}/`, { publish: true }); + return response.data; + } catch (err) { + toast.error('Publikacja projektu nie powiodła się'); + return null; + } +}; + +export default { + getProjects, + getProjectById, + createNewProject, + publishProject, +}; diff --git a/evg-outbo-web/src/API/projectsDeprecated.ts b/evg-outbo-web/src/API/projectsDeprecated.ts new file mode 100755 index 0000000..4a24889 --- /dev/null +++ b/evg-outbo-web/src/API/projectsDeprecated.ts @@ -0,0 +1,689 @@ +import API from './axiosConfig'; + +export enum ProductStageGroup { + INVALID = 0, + GROUP_1 = 1, + GROUP_2 = 2, + GROUP_3 = 3, + GROUP_4 = 4, + GROUP_5 = 5, +} + +export enum ProductStage { + STAGE_1 = 'waiting_for_brief', + STAGE_1A = 'waiting_for_brief_accept_by_agency', + STAGE_1B = 'waiting_for_brief_accept_by_client', + STAGE_2A = 'waiting_for_draft', + STAGE_2B = 'waiting_for_draft_accept_by_supplier', + STAGE_2C = 'waiting_for_draft_accept_by_client', + STAGE_3A = 'waiting_for_ff', + STAGE_3B = 'waiting_for_ff_accept_by_supplier', + STAGE_3C = 'waiting_for_ff_accept_by_client', + STAGE_4A = 'waiting_for_pf', + STAGE_4B = 'waiting_for_pf_accept_by_supplier', + STAGE_4C = 'waiting_for_pf_accept_by_client', + STAGE_5 = 'completed', +} +export enum PhotoBriefStage { + PUBLISHED = 'published', + COMPLETED = 'completed', + CREATED = 'created', +} + +export enum PhotoBriefPhotoType { + ARRANGEMENT_PHOTO = 'arrangement_photo', + PACKSHOT = 'packshot', + CLIPPING = 'clipping', + EXTRA_PHOTO = 'extra_photo', +} + +export enum PhotoBriefCategory { + MODELS = 'models', + STYLING = 'styling', + SCENERY = 'scenery', +} + +export type PhotoBriefRequest = { + sessionDate: string; + conceptDescription: string; +}; + +export type PhotoBriefResponse = { + id: number; + sessionDate: string; + conceptDescription: string; + stage: PhotoBriefStage; + items: PhotoBriefItemResponse[]; + photos: PhotoBriefPhotoResponse[]; + acceptable: boolean; + editable: boolean; +}; +export type PhotoBriefItemRequest = { + category: PhotoBriefCategory; + title: string; + description: string; +}; + +export type PhotoBriefItemResponse = { + id: number; + category: PhotoBriefCategory; + photos: PhotoBriefItemPhotoResponse[]; + title: string; + description: string; + isAcceptedByClient: boolean; + isAcceptedBySupplier: boolean; + editable: boolean; +}; + +export type PhotoBriefPhotoRequest = { + file: File; + type: PhotoBriefPhotoType; +}; + +export type PhotoBriefPhotoResponse = { + url: string; + type: PhotoBriefPhotoType; + filename: string; +}; + +export type PhotoBriefItemPhotoRequest = { + file: File; +}; + +export type PhotoBriefItemPhotoResponse = { + url: string; + filename: string; +}; + +export interface Photo { + file?: File; + id?: number; + url: string; + filename: string; +} +export interface PhotoResponse { + url: string; + id: number; + filename: string; +} +export interface PhotoRequest { + file: File; +} +export interface Blanking { + file?: File; + filename: string; + size: string; + url: string; + id?: number; +} + +export interface BlankingRequest { + file: File; +} + +export interface BlankingResponse { + url: string; + id: number; +} + +export type PrintingFile = { + id?: number; + file?: File; + url: string; + uploadedAt: Date; + uploadedBy: number; + filename: string; + size: string; + description: string; + version: string; +}; + +export type PrintingFileResponse = { + id: number; + url: string; + uploadedAt: Date; + uploadedBy: number; + description: string; + version: string; + filename: string; + size: string; +}; + +export type FileListItemProps = { + id?: number; + file?: File; + url: string; + uploadedAt: Date; + uploadedBy: number; + filename: string; + size: string; + description: string; + version: string; +}; + +export type PrintingFileRequest = { + file: File; + description: string; +}; + +export type FirstFinalFile = { + id?: number; + file?: File; + url: string; + uploadedAt: Date; + uploadedBy: number; + filename: string; + size: string; + description: string; + version: string; +}; + +export type FirstFinalFileResponse = { + id: number; + url: string; + uploadedAt: Date; + uploadedBy: number; + description: string; + version: string; + filename: string; + size: string; +}; + +export type FirstFinalFileRequest = { + file: File; + description: string; +}; + +export type DraftFile = { + id?: number; + file?: File; + url: string; + uploadedAt: Date; + uploadedBy: number; + filename: string; + size: string; + description: string; + version: string; +}; + +export type DraftFileResponse = { + id: number; + url: string; + uploadedAt: Date; + uploadedBy: number; + description: string; + version: string; + filename: string; + size: string; +}; + +export type ProductFileStageFileRequest = { + file: File; + description: string; +}; + +export type DraftFileRequest = { + file: File; + description: string; +}; +// export type Photo = PhotoRequest | PhotoResponse; +// export type Blanking = BlankingRequest | BlankingResponse; + +export interface ProductRequest { + productNamePol: string; + productNameEng: string; + modelNo: string; + batchNo: string; + ean: string; + mainDescription: string; + extraDescription: string; + material: string; + cleaningMethod: string; + countryOfOrigin: string; + warning: string; + arrangementPhotos: string; + packshot: string; + extraPhoto: string; + clippings: string; + illustration: string; + manual: string; + packagingType: number | null; + hasPhotos: boolean; + hasIllustrations: boolean; + isCleaningMethod: boolean; + isClipping: boolean; + certificate: string; + capabilitiesIcons: string; + productIcons: string; + informationSigns: string; + photos: Photo[]; + blankings: Blanking[]; + isCountryOfOrigin: boolean; + isDescriptionEng: boolean; + isDescriptionPol: boolean; + isDescriptionPor: boolean; + isDescriptionRus: boolean; + isExtraPhoto: boolean; + isManual: boolean; + isMaterial: boolean; + isPackshot: boolean; + isWarnings: boolean; +} + +export interface ProductResponse { + acceptable: boolean; + editable: boolean; + publishable: boolean; + id: number; + productNamePol: string; + productNameEng: string; + modelNo: string; + batchNo: string; + ean: string; + mainDescription: string; + extraDescription: string; + material: string; + cleaningMethod: string; + countryOfOrigin: string; + warning: string; + arrangementPhotos: string; + packshot: string; + extraPhoto: string; + clippings: string; + illustration: string; + manual: string; + stage: ProductStage; + photos: Photo[]; + blankings: Blanking[]; + draftFiles: DraftFile[]; + firstFinalFiles: FirstFinalFile[]; + printingFiles: PrintingFile[]; + photoBrief: PhotoBriefResponse; + packagingType: number | null; + certificate: string; + capabilitiesIcons: string; + productIcons: string; + informationSigns: string; + hasPhotos: boolean; + hasIllustrations: boolean; + isCleaningMethod: boolean; + isClipping: boolean; + isCountryOfOrigin: boolean; + isDescriptionEng: boolean; + isDescriptionPol: boolean; + isDescriptionPor: boolean; + isDescriptionRus: boolean; + isExtraPhoto: boolean; + isManual: boolean; + isMaterial: boolean; + isPackshot: boolean; + isWarnings: boolean; +} + +export interface ProjectResponse { + maxProductStage: string; + isPublished: boolean; + publishable: boolean; + slug: string; + name: string; + description: string; + products: ProductResponse[]; + category: number; + brand: number; + printingFilesDate: string; + promoDate: string; + id: number; +} + +export interface ProjectRequest { + name: string; + description: string; + category: number | null; + brand: number | null; + productsAmount: number; + printingFilesDate: string; + promoDate: string; +} + +export const fetchProjects = async (): Promise => { + try { + const response = await API.get('/projects/'); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const fetchProjectById = async (projectId: number) => { + try { + const response = await API.get(`/projects/${projectId}/`); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postNewProject = async (data: ProjectRequest): Promise => { + try { + const response = await API.post('/projects/', data); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const uploadProductPhoto = async (id: number, photo: PhotoRequest): Promise => { + try { + const data = new FormData(); + data.append('file', photo.file); + const response = await API.post(`products/${id}/photos/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const removeProductImage = async (photoId: number): Promise => { + try { + await API.delete(`/photos/${photoId}/`); + return true; + } catch (err) { + console.error(err); + return false; + } +}; + +const uploadProductBlanking = async (productId: number, blanking: BlankingRequest): Promise => { + try { + const data = new FormData(); + data.append('file', blanking.file); + const response = await API.post(`products/${productId}/blankings/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const removeProductBlanking = async (blankingId: number): Promise => { + try { + await API.delete(`/blankings/${blankingId}/`); + return true; + } catch (err) { + return false; + } +}; + +export const patchProduct = async (id: number, data: Partial): Promise => { + try { + const { photos, blankings, ...values } = data; + if (photos && photos.length > 0) { + // TODO: investigate & refactor + await photos + .filter((photo) => 'file' in photo && photo.file !== undefined) + .map(({ file }) => ({ file } as PhotoRequest)) + .reduce(async (previous, photo) => { + await previous; + return uploadProductPhoto(id, photo); + }, Promise.resolve(null)); + } + + if (blankings && blankings.length > 0) { + // TODO: investigate & refactor + await blankings + .filter((blanking) => 'file' in blanking) + .map(({ file }) => ({ file } as BlankingRequest)) + .reduce(async (previous, blanking) => { + await previous; + return uploadProductBlanking(id, blanking); + }, Promise.resolve(null)); + } + + const response = await API.patch(`/products/${id}/`, values); + + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approveProduct = async (id: number): Promise => { + try { + const response = await API.patch(`/products/${id}/`, { accept: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const declineProduct = async (id: number): Promise => { + try { + const response = await API.patch(`/products/${id}/`, { accept: false }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approveProject = async (projectId: number): Promise => { + try { + const response = await API.patch(`/projects/${projectId}/`, { publish: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const announceProduct = async (id: number): Promise => { + try { + const response = await API.patch(`/products/${id}/`, { publish: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postDraft = async (draftId: number, draft: DraftFileRequest): Promise => { + try { + const data = new FormData(); + data.append('file', draft.file); + data.append('description', draft.description); + const response = await API.patch(`/drafts/${draftId}/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approveDraft = async (productId: number): Promise => { + try { + const response = await API.patch(`/products/${productId}/`, { accept: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const fetchProductById = async (productId: number): Promise => { + try { + const response = await API.get(`/products/${productId}/`); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const patchPhotoBrief = async ( + productId: number, + photoBrief: PhotoBriefRequest +): Promise => { + try { + const response = await API.patch(`/products/${productId}/photo-briefing/`, photoBrief); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approvePhotoBriefItem = async ( + photoBriefId: number, + category: PhotoBriefCategory, + id: number | null +): Promise => { + try { + const response = await API.patch(`/photo-briefing/${photoBriefId}/items/`, { + category, + accept: id, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approvePhotoBrief = async (productId: number) => { + try { + const response = await API.patch(`/products/${productId}/photo-briefing/`, { + accept: true, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const patchPublishPhotoBrief = async (productId: number): Promise => { + try { + const response = await API.patch(`/products/${productId}/photo-briefing/`, { publish: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postPhotoBriefItem = async ( + photoBriefId: number, + photoBriefItem: PhotoBriefItemRequest +): Promise => { + try { + const response = await API.post(`/photo-briefing/${photoBriefId}/items/`, photoBriefItem); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postPhotoBriefItemPhoto = async ( + itemId: number, + photo: PhotoBriefItemPhotoRequest +): Promise => { + try { + const data = new FormData(); + data.append('file', photo.file); + const response = await API.post(`/photo-briefing-items/${itemId}/photos/`, data); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +// export const getProjectBrands = async (): Promise => {}; + +export const postFirstFinal = async ( + productId: number, + draft: FirstFinalFileRequest +): Promise => { + try { + const data = new FormData(); + data.append('file', draft.file); + data.append('description', draft.description); + const response = await API.post(`/products/${productId}/first-final-files/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approveFirstFinal = async (productId: number): Promise => { + try { + const response = await API.patch(`/products/${productId}/`, { accept: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postPrintingFile = async ( + productId: number, + draft: PrintingFileRequest +): Promise => { + try { + const data = new FormData(); + data.append('file', draft.file); + data.append('description', draft.description); + const response = await API.post(`/products/${productId}/printing-files/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const approvePrintingFile = async (productId: number): Promise => { + try { + const response = await API.patch(`/products/${productId}/`, { accept: true }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +export const postPhotoBriefPhoto = async ( + photoBriefId: number, + photoBriefPhoto: PhotoBriefPhotoRequest +): Promise => { + try { + const data = new FormData(); + data.append('file', photoBriefPhoto.file); + data.append('type', photoBriefPhoto.type); + const response = await API.post(`/photo-briefing/${photoBriefId}/photos/`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; diff --git a/evg-outbo-web/src/API/userAuthorization.ts b/evg-outbo-web/src/API/userAuthorization.ts new file mode 100755 index 0000000..cfa3780 --- /dev/null +++ b/evg-outbo-web/src/API/userAuthorization.ts @@ -0,0 +1,63 @@ +import API, { TOKEN } from './axiosConfig'; +import { history } from '../App'; +import { toast } from 'react-toastify'; +import { + AuthorizationRequest, + UserDataResponse, + AuthorizationResponse, + UserListItem, +} from 'interfaces/API/userAuthorization'; + +const userAuthorization = async ({ username, password }: AuthorizationRequest): Promise => { + try { + const response = await API.post('/token/', { username, password }); + const token = response.data.token; + localStorage.setItem(TOKEN, token); + return true; + } catch (err) { + if (err.response) { + toast.error(err.response.data.nonFieldErrors[0]); + } else { + toast.error('Wystąpił problem. Sprobuj ponownie.'); + } + return false; + } +}; + +const fetchCurrentUser = async (): Promise => { + try { + const response = await API.get('/users/me/'); + return response.data; + } catch (err) { + console.error(err); + return null; + } +}; + +const logout = async (): Promise => { + try { + localStorage.removeItem(TOKEN); + history.push('/login'); + return await Promise.resolve(true); + } catch (err) { + console.error(err); + return await Promise.resolve(false); + } +}; + +const fetchAllUsers = async (): Promise => { + try { + const response = await API.get('/users/'); + return response.data; + } catch (err) { + console.error(err); + return []; + } +}; + +export default { + userAuthorization, + fetchCurrentUser, + logout, + fetchAllUsers, +}; diff --git a/evg-outbo-web/src/App.scss b/evg-outbo-web/src/App.scss new file mode 100755 index 0000000..0c892b2 --- /dev/null +++ b/evg-outbo-web/src/App.scss @@ -0,0 +1,1067 @@ +@import url("https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap"); +$brief-text-color: #b3b3b3; +@import "./bulma.scss"; +@import "./scss/Variables"; + +html, +body { + font-family: "Roboto"; + font-weight: 400; + overflow-y: auto; + + ::-webkit-scrollbar { + width: 0.375rem; + } + + ::-webkit-scrollbar-track { + // -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.075); + border-radius: 0.1875rem; + background-color: $scrollbar-track-color; + } + + ::-webkit-scrollbar-thumb { + border-radius: 0.1875rem; + background-color: $scrollbar-color; + + &:hover { + background-color: $scrollbar-hover-color; + } + } +} + +.progress-bar { + position: relative; + height: 0; + + &__progress { + height: 0.25rem; + background-color: $orange; + } +} + +.pointer { + cursor: pointer; +} + +.wrap { + flex-wrap: wrap; +} + +.items-spaced { + display: flex; + justify-content: space-between; +} + +.items-right { + display: flex; + justify-content: flex-end; +} + +.column.has-items-right, +.columns.has-items-right { + display: flex; + justify-content: flex-end; +} + +.column.has-items-centered, +.columns.has-items-centered { + display: flex; + align-items: center; +} + +.columns.is-spaced { + justify-content: space-between; +} + +.not-transparent-on-hover { + &:hover { + opacity: 1 !important; + } +} + +.scale-on-hover { + &:hover { + transform: scale(1.1); + cursor: pointer; + } +} + +.error { + color: red; + font-size: 0.75rem; +} + +.moidal-content { + flex: 1; +} + +.field-section > .rows > .row, +.modal-content.rows > .row { + padding: 0.5rem !important; +} + +.rows { + display: flex; + flex-direction: column; + padding: 0.75rem; +} + +.row { + display: flex; + margin: 0 !important; +} + +.rows .row { + flex: 1; +} + +.row.rows { + padding: 0; +} + +.row > .column { + padding: 0; +} + +.horizontal-line { + flex: 1; + display: flex; + justify-content: center; + align-self: center; + margin: 0 0.5rem; + border-bottom: 1px solid #f5f5f5; +} + +.bolder { + font-weight: 500; +} + +.caret { + cursor: pointer; + height: $caret-icon-size; + width: $caret-icon-size; + transition: all 0.3s ease-out; + + &--closed { + transform: translate(-50%, -50%); + transform: rotate(0deg); + } + + &--opened { + transform: translate(-50%, -50%); + transform: rotate(90deg); + } + + &--white { + fill: #fff; + } +} + +.project { + padding: 0.25rem 0.5rem; + &__product-count { + font-size: 0.5625rem; + border-radius: 0.25rem; + background-color: #cccccc; + color: #fff; + padding: 0.0625rem 0.25rem; + margin-right: 0.375rem; + } + &__slug { + text-transform: uppercase; + font-size: $font-title; + margin: 0.5rem 0 0 0; + color: $dark-grey; + + &--active { + color: $link-active-color !important; + font-weight: 700; + } + + &:hover { + color: $link-hover-color; + cursor: pointer; + } + } + + &__name { + margin-left: 0.5rem; + font-size: 0.8125rem; + } +} + +.no-border { + border: none !important; + box-shadow: none !important; +} + +.step-progress { + padding: 2rem 2rem 1rem 2rem; +} + +.steps.step-progress .steps-marker.is-white.tooltip { + color: #fff !important; +} + +.navbar-item > .avatar { + height: 2.25rem !important; + width: 2.25rem !important; + + img { + height: inherit; + width: inherit; + border-radius: 50%; + max-height: 2.25rem !important; + } +} + +.stage-bar { + display: flex; + flex-direction: column; + + &__box { + display: flex; + height: $stage-bar-box-size; + width: $stage-bar-box-size; + border-width: 1px 2px 1px 0px; + border-style: solid; + border-color: $stage-bar-box-border-color; + justify-content: center; + align-items: center; + + &--active { + border-right: none; + border-left: 3px solid #f3d124; + cursor: pointer; + } + + &--active-icon { + border-right: none; + border-left: 3px solid #949494; + } + + &:first-child { + border-top: none; + } + + &__item { + display: flex; + justify-content: center; + align-items: center; + height: $stage-bar-item-size; + width: $stage-bar-item-size; + padding: 7px; + border: 3px solid $stage-bar-item-border-color; + border-radius: 100%; + color: #666666; + font-size: 0.5625rem; + + svg { + height: 20px; + width: 20px; + } + + &--disabled { + opacity: 0.5; + background-color: $stage-bar-item-border-color; + } + + &--active { + border: none; + background-color: #f58c39; + border: 3px solid $stage-bar-item-border-color-active; + + svg { + height: 20px; + width: 20px; + } + } + } + } +} + +.brief-container { + box-shadow: $container-box-shadow; + background-color: #fff; + min-height: $container-height; +} + +.centered { + display: flex; + justify-content: center; + align-items: center; +} + +.retry-container { + @extend .brief-container; + display: flex; + justify-content: center; + align-items: center; +} + +.retry-dashboard { + @extend .brief-container; + box-shadow: none; + display: flex; + justify-content: center; + align-items: center; +} + +.product { + margin-left: 0 !important; + margin-right: 0 !important; + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + + &__id { + margin-left: 0.5rem; + color: $light-grey; + &::first-line{ + line-height: 1.25rem; + } + + &:hover { + color: $link-hover-color; + cursor: pointer; + } + + &--active { + color: $link-active-color; + } + } +} + +.relative { + position: relative; +} + +.login { + &__container { + padding: 2rem; + background-color: $bg-light; + border-radius: $container-border-radius; + box-shadow: $container-box-shadow; + } + + &__pos { + justify-content: center; + } + + &__background { + height: 100vh; + + img { + height: 100vh; + width: 100%; + object-fit: fill; + } + } + + &__logo { + height: 3.25rem; + } + + &__icon { + position: absolute; + left: 0.5rem; + top: 1rem; + } +} + +.stage-stamp { + display: flex; + justify-content: center; + align-items: center; + width: 30px; + height: 20px; + border-radius: 0.3125rem; + font-size: 0.7rem; + + &__label { + color: #fff; + } + + &__light-bg { + background-color: $light-grey; + } +} + +.container-header { + background-color: $grey; + border-radius: $header-border-radius; +} + +.left-header { + @extend .container-header; + & div > div > button:nth-of-type(1) { + margin-left: 1rem !important; + } + &__title { + margin-left: 0.75rem; + text-transform: uppercase; + font-weight: 500; + color: #fff; + } +} + +.order-button { + &:nth-child(2) { + margin-left: 2rem; + } + padding: 0.375rem 0.5rem !important; + font-size: 0.5rem !important; + margin: 0 0.125rem !important; + &__label { + color: #fff; + font-size: 0.5rem !important; + line-height: 0; + } +} + +.right-header { + @extend .container-header; + padding: 1rem; + + &__slug { + font-weight: 500; + text-transform: uppercase; + font-size: 1rem; + color: #fff; + } + + &__model { + margin-left: 0.55rem; + font-size: 0.9125rem; + color: $very-light-grey; + } +} + +.project-members-header { + @extend .container-header; + padding: 1rem; + margin-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; + + &__title { + margin-left: 0.875rem; + text-transform: uppercase; + font-weight: 500; + color: #fff; + } +} + +.project-members-container { +} + +.project-members-footer { + box-shadow: $container-box-shadow; + + border-radius: $container-border-radius; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $bg-light; + + height: 5vh; + + &__icon { + display: flex; + align-items: center; + justify-content: center; + } +} + +.project-members-content { + // box-shadow: $container-box-shadow; + + min-height: $project-members-content; + max-height: $project-members-content + 5; + background-color: $bg-light; + overflow: auto; + + &__closed { + display: none; + } +} + +.project-members-add-new { + display: flex; + justify-content: center; + align-items: center; + margin: 1.5rem 0; +} + +.project-member-item { + border-bottom: 1px solid #f5f5f5; + padding: 0.5rem; + + &__image { + display: flex; + justify-content: center; + align-items: center; + + img { + border-radius: 50%; + height: 36px; + width: 36px; + // object-fit: contain; + } + } + + &__full-name { + font-size: 0.8125rem; + font-weight: 500; + color: #707070; + } + + &__phoneNumber { + font-size: 0.8125rem; + color: #b3b3b3; + } + + &__email { + font-size: 0.75rem; + font-weight: 500; + color: #b3b3b3; + } + + &__jobTitle { + font-size: 0.6875rem; + font-weight: 500; + color: #b3b3b3; + } +} + +.search-bar { + margin-bottom: 0.25rem; + + &__input { + box-shadow: $container-box-shadow; + border-radius: $container-border-radius; + font-size: 1rem !important; + padding: 1.5rem !important; + } +} + +.full-height-column { + // border-radius: 0 0 $container-bottom-border-radius $container-bottom-border-radius; + min-height: auto; + max-height: vh; + background-color: $bg-light; + display: flex; + flex-grow: 1; + flex-direction: column; + overflow: auto; +} + +.selected-project-container { + background-color: #ededed; + display: flex; + flex-direction: column; +} + +.activity-log { + box-shadow: $container-box-shadow; + border-radius: $container-border-radius; + + &__content { + min-height: $activity-log-height; + max-height: $activity-log-height + 5; + overflow: auto; + background-color: $bg-light; + } + + &__footer { + background-color: $bg-light; + border-top: 1.5px solid rgba($medium-grey, 0.15); + } + + &__author { + font-size: 0.9375rem; + font-weight: 500; + color: $medium-grey; + } + + &__date { + font-size: 0.75rem; + } + + &__description { + margin-left: 0.25rem; + font-size: 0.8125rem; + } +} + +.image-picker, +.picker, +.image { + display: flex; + align-items: center; + justify-content: center; + + &__with-remove { + position: relative; + + svg { + position: absolute; + top: 0.25rem; + right: 0.25rem; + opacity: 0.5; + } + } + + &__box { + position: relative; + border: $thick-border solid #f5f5f5; + background-color: #ffffff; + margin: 0.125rem; + width: 160px; + height: 100px; + + img { + width: 160px - $thick-border * 2; + height: 100px - $thick-border * 2; + object-fit: cover; + } + + &--big { + width: 200px; + height: 200px; + + img { + width: 200px - $thick-border * 2; + height: 200px - $thick-border * 2; + object-fit: cover; + } + } + + &--small { + width: 90px; + height: 90px; + + img { + width: 90px; + height: 90px; + object-fit: cover; + } + } + + &--big-hoverable { + color: white; + position: absolute; + top: 0; + opacity: 0; + width: 200px - $thick-border * 2; + height: 200px - $thick-border * 2; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + + &:hover { + opacity: 1; + cursor: pointer; + } + } + } + + &__input { + display: none; + } +} + +.create-message { + padding: 0.5rem 0.25rem; +} + +.brief { + width: 100%; + display: flex; + border-top: 2px solid #f2f2f2; + + &__title { + font-weight: 500; + color: $dark-grey; + text-transform: uppercase; + } + + &__container { + min-width: 525px; + flex-grow: 1; + } + + &__section-title { + color: $light-grey; + } + + &__input { + padding: 0.625rem; + height: auto; + + &--select { + width: 5rem !important; + height: $dropdown-height !important; + + select { + height: inherit !important; + width: inherit !important; + } + } + } + + &__form { + flex-grow: 1; + } +} + +.photo-file { + position: relative; + + &__title { + font-weight: 500; + font-size: 1.0625rem; + color: $medium-grey; + } + + &__description { + font-weight: 400; + font-size: 0.9375rem; + overflow-wrap: break-word; + } + + img { + height: inherit; + width: inherit; + } + + &--hovered { + color: white; + position: absolute; + top: 0; + opacity: 0; + width: inherit; + height: inherit; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + + &:hover { + opacity: 1; + } + } +} + +.file { + background-color: $file-bg; + border: $thick-border solid $file-border; + padding: 0.5rem; + + &__name-background { + height: 3rem; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba($orange, 0.4); + color: $medium-grey; + } + + &:first-child { + margin-top: 0.5rem; + } + + &__size { + color: #bcbcbc; + font-size: 0.75rem !important; + } +} + +.font-small { + font-size: 0.75rem; +} + +.font-dark { + color: #666; +} + +.is-dimmed { + color: $brief-text-color !important; + + svg { + fill: $brief-text-color; + } +} + +.file-list-item { + padding: 0.65rem; + + span { + font-size: 0.75rem; + } + + &__draft { + &--iconAccepted { + fill: $stage-2 !important; + } + + &--nameAccepted { + color: $stage-2 !important; + } + } + + &__ff { + &--iconAccepted { + fill: $stage-3 !important; + } + + &--nameAccepted { + color: $stage-3 !important; + } + } + + &__pf { + &--iconAccepted { + fill: $stage-4 !important; + } + + &--nameAccepted { + color: $stage-4 !important; + } + } + + &:first-child { + margin-top: 0.5rem; + } + + &:last-child { + border-bottom: 1px solid $file-border; + } + + border-top: 1px solid $file-border; + + &--accepted { + color: #f5613c; + } + + &__dialog { + border: $thick-border solid $file-border; + } + + &__modal-filename { + padding: 0.75rem; + height: 120px; + width: 100%; + background-color: rgba($orange, 0.3); + + span { + font-size: 0.75rem; + font-weight: 500; + } + } + + &__modal-description { + margin: 2rem 0 1rem 0; + } +} + +.button-checkbox { + input { + display: none; + } + + margin: 0.25rem 0.25rem 0 0; + color: $light-grey !important; + + &--is-active { + background-color: $orange !important; + color: white !important; + } +} + +.form-checkbox { + display: none; + + & + label { + position: relative; + cursor: pointer; + padding: 0; + } + + & + label:before { + content: ""; + margin-right: 0.375rem; + display: inline-block; + vertical-align: text-top; + width: 20px; + height: 20px; + border-radius: 0.25rem; + background: $checkbox-bg-color; + } + + &[disabled] + label { + cursor: default; + } + + &:hover:not([disabled]) + label:before { + background: $checkbox-hover-color; + } + + &:checked + label:before { + background: $checkbox-checked-color; + } + + &:checked + label:after { + content: ""; + position: absolute; + left: 5px; + top: 9px; + background: white; + width: 2px; + height: 2px; + box-shadow: 2px 0 0 white, 4px 0 0 white, 4px -2px 0 white, 4px -4px 0 white, 4px -6px 0 white, 4px -8px 0 white; + transform: rotate(45deg); + } +} + +.datepicker { + position: relative; + + input { + font-size: 0.75rem; + height: $dropdown-height; + padding-right: $dropdown-height - 1rem; + } + + svg { + position: absolute; + top: 3 * $dropdown-height/10; + right: 3 * $dropdown-height/10; + width: $dropdown-height/2.5; + height: $dropdown-height/2.5; + } + + cursor: pointer; + + span { + margin-right: 0.5rem; + } +} + +.modal-content { + overflow: auto; + background-color: $bg-light; + overflow: hidden; + padding: 0.5rem; + + &__header { + font-size: 1.25rem; + color: $dark-grey; + } + + &__image { + img { + max-height: 60vh; + object-fit: contain; + } + } +} + +.with-icon { + display: flex; + justify-content: space-between; + align-items: center; + + &--left { + svg { + margin: 0 1rem 0 0.15rem; + } + } + + &--right { + svg { + margin: 0 0.15rem 0 1rem; + } + } +} + +@media screen and (max-width: 1430px){ + .stacked { + display: flex; + flex-direction: column; + } +} + +$values: 10; + +@mixin custom-margins { + @for $i from 0 through $values { + .mt-stacked-#{$i}{ + @media screen and (max-width: 1430px){ + margin-top: $i * 8px !important; + } + } + .ml-stacked-#{$i}{ + @media screen and (max-width: 1430px){ + margin-left: $i * 8px !important; + } + } + .mr-stacked-#{$i}{ + @media screen and (max-width: 1430px){ + margin-right: $i * 8px !important; + } + } + .mb-stacked-#{$i}{ + @media screen and (max-width: 1430px){ + margin-bottom: $i * 8px !important; + } + } + .m-#{$i} { + margin: $i * 8px; + } + + .mt-#{$i} { + margin-top: $i * 8px; + } + + .mb-#{$i} { + margin-bottom: $i * 8px; + } + + .ml-#{$i} { + margin-left: $i * 8px; + } + + .mr-#{$i} { + margin-right: $i * 8px; + } + } +} + +@mixin custom-paddings { + @for $i from 0 through $values { + .p-#{$i} { + padding: $i * 8px !important; + } + + .pt-#{$i} { + padding-top: $i * 8px !important; + } + + .pb-#{$i} { + padding-bottom: $i * 8px !important; + } + + .pl-#{$i} { + padding-left: $i * 8px !important; + } + + .pr-#{$i} { + padding-right: $i * 8px !important; + } + } +} + +@include custom-paddings; +@include custom-margins; diff --git a/evg-outbo-web/src/App.tsx b/evg-outbo-web/src/App.tsx new file mode 100755 index 0000000..00393b6 --- /dev/null +++ b/evg-outbo-web/src/App.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import store from './store/store'; +import './App.scss'; +import { Switch, Router, Redirect } from 'react-router-dom'; +import { Login, Main } from 'containers'; +import { PrivateRoute, NotAuthorizedRoute } from 'helpers/PrivateRoute'; +import { createBrowserHistory } from 'history'; +import 'react-image-lightbox/style.css'; +import { ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +export const history = createBrowserHistory(); + +const App: React.FC = () => { + return ( + + + + + + + + + +
+ + + + + + ); +}; + +export default App; diff --git a/evg-outbo-web/src/__mocks__/API/products.ts b/evg-outbo-web/src/__mocks__/API/products.ts new file mode 100755 index 0000000..c33ef0e --- /dev/null +++ b/evg-outbo-web/src/__mocks__/API/products.ts @@ -0,0 +1,9 @@ +const getProductById = jest.fn(); +const getProducts = jest.fn(); +const getProductsByURL = jest.fn(); + +export default { + getProductById, + getProducts, + getProductsByURL, +}; diff --git a/evg-outbo-web/src/__mocks__/API/projects.ts b/evg-outbo-web/src/__mocks__/API/projects.ts new file mode 100755 index 0000000..8829692 --- /dev/null +++ b/evg-outbo-web/src/__mocks__/API/projects.ts @@ -0,0 +1,11 @@ +const getProjects = jest.fn(); +const getProjectById = jest.fn(); +const createNewProject = jest.fn(); +const publishProject = jest.fn(); + +export default { + getProjects, + getProjectById, + createNewProject, + publishProject, +}; diff --git a/evg-outbo-web/src/__mocks__/API/userAuthorization.ts b/evg-outbo-web/src/__mocks__/API/userAuthorization.ts new file mode 100755 index 0000000..1d05536 --- /dev/null +++ b/evg-outbo-web/src/__mocks__/API/userAuthorization.ts @@ -0,0 +1,7 @@ +const userAuthorization = jest.fn().mockReturnValue(true); +const fetchCurrentUser = jest.fn(); + +export default { + userAuthorization, + fetchCurrentUser, +}; diff --git a/evg-outbo-web/src/__mocks__/fileMock.js b/evg-outbo-web/src/__mocks__/fileMock.js new file mode 100755 index 0000000..08d725c --- /dev/null +++ b/evg-outbo-web/src/__mocks__/fileMock.js @@ -0,0 +1 @@ +export default ''; diff --git a/evg-outbo-web/src/__mocks__/hooks/useCurrentBrief.ts b/evg-outbo-web/src/__mocks__/hooks/useCurrentBrief.ts new file mode 100755 index 0000000..3dbce55 --- /dev/null +++ b/evg-outbo-web/src/__mocks__/hooks/useCurrentBrief.ts @@ -0,0 +1,3 @@ +const useCurrentBrief = jest.fn(); + +export default useCurrentBrief; diff --git a/evg-outbo-web/src/__mocks__/hooks/useCurrentProject.ts b/evg-outbo-web/src/__mocks__/hooks/useCurrentProject.ts new file mode 100755 index 0000000..24bcb6d --- /dev/null +++ b/evg-outbo-web/src/__mocks__/hooks/useCurrentProject.ts @@ -0,0 +1,3 @@ +const useCurrentProject = jest.fn(); + +export default useCurrentProject; diff --git a/evg-outbo-web/src/__mocks__/hooks/useNumberParams.ts b/evg-outbo-web/src/__mocks__/hooks/useNumberParams.ts new file mode 100755 index 0000000..29c5c74 --- /dev/null +++ b/evg-outbo-web/src/__mocks__/hooks/useNumberParams.ts @@ -0,0 +1,3 @@ +const useNumberParams = jest.fn(); + +export default useNumberParams; diff --git a/evg-outbo-web/src/__tests__/App.test.tsx b/evg-outbo-web/src/__tests__/App.test.tsx new file mode 100755 index 0000000..823bacb --- /dev/null +++ b/evg-outbo-web/src/__tests__/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from '../App'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/evg-outbo-web/src/__tests__/LeftSidebar.test.tsx b/evg-outbo-web/src/__tests__/LeftSidebar.test.tsx new file mode 100755 index 0000000..4512ff1 --- /dev/null +++ b/evg-outbo-web/src/__tests__/LeftSidebar.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { cleanup, render, waitForElement, act, fireEvent } from '@testing-library/react'; +import LeftSidebar from 'organisms/LeftSidebar'; +import { createMemoryHistory, createLocation } from 'history'; +import configureStore from 'redux-mock-store'; //ES6 modules +import { ProductStage, AnyProject } from 'interfaces/API/projects'; +import { Provider } from 'react-redux'; +import { Router, Route, match } from 'react-router-dom'; +import { ProductStore } from 'interfaces/API/products'; +import { ProductStageResponse, Category, Brand } from 'API/projectData'; +import '@testing-library/jest-dom/extend-expect'; + +afterEach(cleanup); + +const category: Category = { + id: 1, + name: 'toys', +}; +const brand: Brand = { + id: 1, + name: 'boys', +}; +const productStage: ProductStageResponse = { + id: ProductStage.STAGE_1, + label: '1', + group: 1, + description: 'Briefing', +}; +const printingFilesDate = '2011-02-01'; +const promoDate = '2011-01-01'; + +const renderWithProviderAndRoute = (ui: any, { routeOptions, history, store }: any): any => { + return { + ...render( + + + {ui} + + + ), + }; +}; +const createMockStore = (initialProjectValues: AnyProject[], initialProductValues: ProductStore) => { + const mockStore = configureStore(); + const store = mockStore({ + brief: { briefs: [] }, + projects: { + projectsList: initialProjectValues, + }, + products: { + currentStage: 0, + productsList: [{ ...initialProductValues }], + }, + users: { + currentUser: { + id: 1, + organization: { + type: 'supplier', + }, + }, + usersList: [], + }, + activityLog: { messages: [] }, + drafts: { draftList: [] }, + firstFinals: { firstFinalList: [] }, + printingFiles: { printingFilesList: [] }, + progress: { progress: null }, + projectData: { + categories: [category], + brands: [brand], + productStages: [productStage], + packagingTypes: [], + }, + }); + return store; +}; + +const routeConfiguration = (path: string, params?: any) => { + const history = createMemoryHistory({ initialEntries: [path] }); + + const match: match = { + isExact: true, + path, + url: path, + params: {}, + }; + const location = createLocation(match.url); + return { history, match, location }; +}; + +describe('LeftSidebar', () => { + const products: ProductStore = { + id: 1, + displayName: 'test / test', + project: 1, + stage: ProductStage.STAGE_1, + brief: '', + draft: '', + firstFinal: '', + printingFiles: '', + createdAt: new Date(), + updatedAt: new Date(), + }; + const projects: AnyProject[] = [ + { + id: 1, + slug: 'firstProjectSlug', + name: 'firstProjectName', + description: 'firstProjectDesc', + printingFilesDate: printingFilesDate, + productsAmount: 1, + promoDate: promoDate, + products: 'test', + maxProductStage: ProductStage.STAGE_1A, + publishable: false, + published: true, + brand: brand.id, + category: brand.id, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + const path = '/'; + let { history, match, location } = routeConfiguration(path); + afterEach(() => (history = createMemoryHistory({ initialEntries: [path] }))); + const store = createMockStore(projects, products); + it('should render without crash', () => { + renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + }); + it('should render project', async () => { + const { findByTestId } = renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + const projectsList = await waitForElement(() => findByTestId('leftSidebarProjects')); + expect(projectsList.children.length).toBe(projects.length); + }); + it('should render project properties', async () => { + const { getByText } = renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + // const projects = await waitForElement(() => findByTestId('leftSidebarProjects')); + + //FIND PROJECT SLUG + expect(getByText(/firstProjectSlug/i)).toBeInTheDocument(); + + //FIND PROJECT NAMES + expect(getByText(/FirstProjectName/i)).toBeInTheDocument(); + }); + + it('should render product properties', async () => { + const { getByText } = renderWithProviderAndRoute(, { + routeOptions: { exact: true, path }, + history, + store, + }); + + const stamp = getByText(/1/i); + expect(getByText(/test \/ test/i)).toBeInTheDocument(); + expect(stamp.parentElement).toHaveClass('is-background-stage-1'); + }); + it('should collapse product list', async () => { + const { getByTestId, queryByText } = renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + const leftSidebarCaret = getByTestId('leftSidebarCaret'); + expect(leftSidebarCaret).toHaveClass('caret--opened'); + await act(async () => { + fireEvent.click(leftSidebarCaret); + }); + expect(leftSidebarCaret).toHaveClass('caret--closed'); + expect(leftSidebarCaret).not.toHaveClass('caret--opened'); + expect(queryByText(/test \/ test/i)).not.toBeInTheDocument(); + }); + it('should redirect to project on project click', async () => { + const { getByText } = renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + const projectSlug = getByText(/firstProjectSlug/i); + await act(async () => { + fireEvent.click(projectSlug); + }); + expect(history.location.pathname).toBe('/projects/1'); + }); + it('should redirect to product on product click', async () => { + const { getByText } = renderWithProviderAndRoute(, { + routeOptions: { exact: false, path }, + history, + store, + }); + + const productName = getByText(/test \/ test/i); + + await act(async () => { + fireEvent.click(productName); + }); + expect(history.location.pathname).toBe('/projects/1/products/1'); + }); +}); diff --git a/evg-outbo-web/src/__tests__/Login.test.tsx b/evg-outbo-web/src/__tests__/Login.test.tsx new file mode 100755 index 0000000..2a18208 --- /dev/null +++ b/evg-outbo-web/src/__tests__/Login.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import LoginForm from 'organisms/LoginForm'; +import '@testing-library/jest-dom/extend-expect'; +import { render, cleanup, waitForElement, fireEvent, act, wait } from '@testing-library/react'; +import userApi from 'API/userAuthorization'; + +afterEach(cleanup); + +describe('Login', () => { + let history = createMemoryHistory({ initialEntries: ['/login'] }); + + afterEach(() => (history = createMemoryHistory({ initialEntries: ['/login'] }))); + + const dispatch = jest.fn(); + + it('renders login form properly', async () => { + const { findByTestId } = render(); + const passwordField = await waitForElement(() => findByTestId('password-field')); + const usernameField = await waitForElement(() => findByTestId('username-field')); + const submitButton = await waitForElement(() => findByTestId('submit-button')); + + expect(submitButton.firstChild?.textContent).toBe('Zaloguj'); + expect(passwordField).toHaveValue(''); + expect(usernameField).toHaveValue(''); + }); + it('should display a blank login form', async () => { + const { findByTestId } = render(); + + const loginForm = await findByTestId('login-form'); + + expect(loginForm).toHaveFormValues({ + username: '', + password: '', + }); + }); + it('should allow entering username and password', async () => { + const { findByTestId } = render(); + const passwordField = await waitForElement(() => findByTestId('password-field')); + const usernameField = await waitForElement(() => findByTestId('username-field')); + await act(async () => { + fireEvent.change(passwordField, { target: { value: 'password' } }); + fireEvent.change(usernameField, { target: { value: 'username' } }); + }); + expect(passwordField).toHaveValue('password'); + expect(usernameField).toHaveValue('username'); + }); + it('should not submit blank form', async () => { + const { findByTestId, getByText } = render(); + + const loginForm = await findByTestId('login-form'); + fireEvent.submit(loginForm); + + const usernameError = await waitForElement(() => getByText('Podaj nazwę użytkownika.')); + const passwordError = await waitForElement(() => getByText('Podaj hasło.')); + + expect(usernameError.innerHTML).toEqual('Podaj nazwę użytkownika.'); + expect(passwordError.innerHTML).toEqual('Podaj hasło.'); + }); + it('should submit form with given values and redirects', async () => { + const { findByTestId, getByText } = render(); + const passwordField = await waitForElement(() => findByTestId('password-field')); + const usernameField = await waitForElement(() => findByTestId('username-field')); + const loginForm = await findByTestId('login-form'); + await act(async () => { + fireEvent.change(passwordField, { target: { value: 'password' } }); + fireEvent.change(usernameField, { target: { value: 'username' } }); + }); + await act(async () => { + fireEvent.submit(loginForm); + }); + expect(userApi.userAuthorization).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/'); + }); + it('should display spinner on submitting form', async () => { + const { findByTestId } = render(); + const submitButton = await waitForElement(() => findByTestId('submit-button')); + const passwordField = await waitForElement(() => findByTestId('password-field')); + const usernameField = await waitForElement(() => findByTestId('username-field')); + const loginForm = await findByTestId('login-form'); + await act(async () => { + fireEvent.change(usernameField, { target: { value: 'username' } }); + }); + await act(async () => { + fireEvent.change(passwordField, { target: { value: 'password' } }); + }); + + fireEvent.submit(loginForm); + expect(submitButton).toHaveClass('is-loading'); + await wait(() => expect(submitButton).not.toHaveClass('is-loading')); + }); + it('should not redirect on invalid data', async () => { + userApi.userAuthorization.mockReturnValueOnce(false); + const { findByTestId } = render(); + const passwordField = await waitForElement(() => findByTestId('password-field')); + const usernameField = await waitForElement(() => findByTestId('username-field')); + const loginForm = await findByTestId('login-form'); + await act(async () => { + fireEvent.change(passwordField, { target: { value: 'password' } }); + }); + await act(async () => { + fireEvent.change(usernameField, { target: { value: 'username' } }); + }); + await act(async () => { + fireEvent.submit(loginForm); + }); + expect(history.location.pathname).toBe('/login'); + }); +}); diff --git a/evg-outbo-web/src/__tests__/ProductBrief.test.tsx b/evg-outbo-web/src/__tests__/ProductBrief.test.tsx new file mode 100755 index 0000000..0527881 --- /dev/null +++ b/evg-outbo-web/src/__tests__/ProductBrief.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { createMemoryHistory, createLocation } from 'history'; +import { Provider } from 'react-redux'; +import { Route, Router, match } from 'react-router'; +import '@testing-library/jest-dom/extend-expect'; +import { render, cleanup, waitForElement, fireEvent, act, wait } from '@testing-library/react'; +import configureStore from 'redux-mock-store'; +import ProductBrief from 'organisms/ProductBrief'; +import useCurrentBrief from 'hooks/useCurrentBrief'; +import useNumberParams from 'hooks/useNumberParams'; + +afterEach(cleanup); + +const createMockStore = (initialValues: any) => { + const mockStore = configureStore(); + const store = mockStore({ + projectData: { + packagingTypes: [], + categories: [], + brands: [], + }, + users: { + currentUser: { + id: 1, + organization: { + type: 'supplier', + }, + }, + usersList: [], + }, + projects: { + projectsList: [ + { + id: 1, + slug: '#WOIM1-4919', + name: 'Test123', + products: 'http://172.16.62.55:8000/products/?project=1', + printingFilesDate: '2019-12-12', + maxProductStage: 'waiting_for_draft', + published: true, + createdAt: '2019-12-12T10:29:02.860Z', + updatedAt: '2019-12-13T08:56:52.806Z', + }, + ], + }, + products: { + productsList: [ + { + id: 1, + displayName: 'test / test', + project: 1, + stage: 'waiting_for_draft', + brief: 'http://test/briefs/1/', + draft: 'http://test/drafts/1/', + firstFinal: null, + printingFiles: null, + photoBrief: 'http://test/photobriefing/1/', + createdAt: '2019-12-12T10:29:02.925Z', + updatedAt: '2019-12-13T08:56:52.788Z', + }, + ], + }, + brief: { + briefs: [ + { + initialValues, + }, + ], + }, + }); + return store; +}; + +const renderWithProviderAndRoute = (ui: any, { routeOptions, history, store }: any): any => { + return { + ...render( + + + {ui} + + + ), + }; +}; + +const routeConfiguration = (path: string, params?: any) => { + const history = createMemoryHistory({ initialEntries: [path] }); + const match: match<{ id: string; pid: string }> = { + isExact: true, + path, + url: path, + params: params ? params : {}, + }; + const location = createLocation(match.url); + return { history, match, location }; +}; + +describe('ProductBrief', () => { + // let history = createMemoryHistory({ initialEntries: ['/login'] }); + const initialValues = { + namePol: '', + nameEng: '', + modelNo: '', + batchNo: '', + ean: '', + packagingType: null, + mainDescription: '', + extraDescription: '', + photos: [], + blankings: [], + certificate: '', + informationSigns: '', + capabilitiesIcons: '', + productIcons: '', + hasDescriptionEng: false, + hasDescriptionPol: false, + hasDescriptionPor: false, + hasDescriptionRus: false, + hasDescriptionSpa: false, + illustration: '', + cleaningMethod: '', + countryOfOrigin: '', + manual: '', + material: '', + warning: '', + arrangementPhoto: '', + packshot: '', + clipping: '', + extraPhoto: '', + editable: true, + acceptable: false, + publishable: false, + createdAt: '2020-01-10T08:01:07.703Z', + updatedAt: '2020-01-10T08:01:07.712Z', + id: 1, + }; + const path = `/projects/1/products/1`; + let { history, match, location } = routeConfiguration(path, { id: 1, pid: 1 }); + afterEach(() => (history = createMemoryHistory({ initialEntries: [path] }))); + + const dispatch = jest.fn(); + const store = createMockStore(initialValues); + it.only('renders basic product brief properly', async () => { + useCurrentBrief.mockReturnValue(initialValues); + useNumberParams.mockReturnValue({ + projectId: 1, + productId: 1, + }); + const { getByTestId, getByPlaceholderText, getByText, getByLabelText, getAllByText } = renderWithProviderAndRoute( + , + { + routeOptions: { exact: true, path }, + history, + store, + } + ); + + const productPL = await waitForElement(() => getByPlaceholderText('Nazwa produktu - PL')); + const productEN = await waitForElement(() => getByPlaceholderText('Nazwa produktu - EN')); + const modelNr = await waitForElement(() => getByPlaceholderText('Model nr')); + const batchNo = await waitForElement(() => getByPlaceholderText('Nr. Partii')); + const ean = await waitForElement(() => getByPlaceholderText('EAN')); + const packagingType = await waitForElement(() => getByText('Rodzaj opakowania')); + const photosCheckbox = await waitForElement(() => getByLabelText('Zdjęcia')); + const illustrationCheckbox = await waitForElement(() => getByLabelText('Ilustracje')); + const manualCheckbox = await waitForElement(() => getByLabelText('Zdjęcia')); + const productPhotoPicker = await waitForElement(() => getByTestId('productPhotoPicker')); + const productBlankingPicker = await waitForElement(() => getByTestId('productBlankingPicker')); + const polishLang = await waitForElement(() => getByLabelText('Polski')); + const rusLang = await waitForElement(() => getByLabelText('Rosyjski')); + const angLang = await waitForElement(() => getByLabelText('Angielski')); + const ptLang = await waitForElement(() => getByLabelText('Portugalski')); + const espLang = await waitForElement(() => getByLabelText('Hiszpański')); + const material = await waitForElement(() => getByLabelText('Materiał')); + const cleaningMethod = await waitForElement(() => getByLabelText('Sposób czyszczenia')); + const countryOfOrigin = await waitForElement(() => getByLabelText('Kraj produkcji')); + const warning = await waitForElement(() => getByLabelText('Ostrzeżenia')); + const certificate = await waitForElement(() => getByPlaceholderText('Certyfikaty')); + const informationSigns = await waitForElement(() => getByPlaceholderText('Znaki informacyjne')); + const competenceIcons = await waitForElement(() => getByPlaceholderText('Ikony kompetencji')); + const productIcons = await waitForElement(() => getByPlaceholderText('Ikony produktowe')); + const previousButton = await waitForElement(() => getAllByText('Poprzedni')); + const nextButton = await waitForElement(() => getAllByText('Następny')); + + expect(previousButton).toHaveLength(2); + expect(nextButton).toHaveLength(2); + expect(photosCheckbox.checked).toEqual(false); + expect(illustrationCheckbox.checked).toEqual(false); + expect(productPL).toBeInTheDocument(); + expect(productEN).toBeInTheDocument(); + expect(modelNr).toBeInTheDocument(); + expect(batchNo).toBeInTheDocument(); + expect(ean).toBeInTheDocument(); + expect(packagingType).toBeInTheDocument(); + expect(photosCheckbox).toBeInTheDocument(); + expect(illustrationCheckbox).toBeInTheDocument(); + expect(manualCheckbox).toBeInTheDocument(); + expect(productPhotoPicker).toBeInTheDocument(); + expect(productBlankingPicker).toBeInTheDocument(); + expect(polishLang).toBeInTheDocument(); + expect(rusLang).toBeInTheDocument(); + expect(angLang).toBeInTheDocument(); + expect(ptLang).toBeInTheDocument(); + expect(espLang).toBeInTheDocument(); + expect(material).toBeInTheDocument(); + expect(cleaningMethod).toBeInTheDocument(); + expect(countryOfOrigin).toBeInTheDocument(); + expect(warning).toBeInTheDocument(); + expect(certificate).toBeInTheDocument(); + expect(informationSigns).toBeInTheDocument(); + expect(competenceIcons).toBeInTheDocument(); + expect(productIcons).toBeInTheDocument(); + // const promoDate = await waitForElement(() => findByDisplayValue(initialValues.printingFilesDate)); + // const pfDate = await waitForElement(() => findByDisplayValue(initialValues.promoDate)); + }); +}); diff --git a/evg-outbo-web/src/__tests__/ProjectBrief.test.tsx b/evg-outbo-web/src/__tests__/ProjectBrief.test.tsx new file mode 100755 index 0000000..6478798 --- /dev/null +++ b/evg-outbo-web/src/__tests__/ProjectBrief.test.tsx @@ -0,0 +1,441 @@ +import React from 'react'; +import { createMemoryHistory, createLocation } from 'history'; +import ProjectBrief from 'organisms/ProjectBrief/ProjectBrief'; +import { cleanup, render, waitForElement, fireEvent, act, wait } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { Provider } from 'react-redux'; +import projectApi from 'API/projects'; +import productApi from 'API/products'; +import * as productActions from 'modules/products/actions'; +import { Route, Router, match } from 'react-router'; +import configureStore from 'redux-mock-store'; //ES6 modules +import { Brand, Category } from 'API/projectData'; +import { format } from 'date-fns'; +import useCurrentProject from 'hooks/useCurrentProject'; +import ReactDOM from 'react-dom'; + +afterEach(cleanup); +const dispatch = jest.fn(); + +//MOCKED DATA +const category: Category = { + id: 1, + name: 'toys', +}; +const brand: Brand = { + id: 1, + name: 'boys', +}; +const printingFilesDate = '2011-02-01'; +const promoDate = '2011-01-01'; + +const createMockStore = (initialValues: any) => { + const mockStore = configureStore(); + const store = mockStore({ + projectData: { + categories: [category], + brands: [brand], + }, + users: { + currentUser: { + id: 1, + organization: { + type: 'supplier', + }, + }, + usersList: [], + }, + projects: { + projectsList: [{ ...initialValues }], + }, + }); + return store; +}; + +const routeConfiguration = (path: string, params?: any) => { + const history = createMemoryHistory({ initialEntries: [path] }); + const match: match = { + isExact: true, + path, + url: path, + params: params ? params : {}, + }; + const location = createLocation(match.url); + return { history, match, location }; +}; + +const renderWithProviderAndRoute = (ui: any, { routeOptions, history, store }: any): any => { + return { + ...render( + + + {ui} + + + ), + }; +}; + +describe('new project', () => { + const path = `/projects/new`; + let { history, match, location } = routeConfiguration(path); + beforeEach(() => (history = createMemoryHistory({ initialEntries: [path] }))); + + const initialValues = { + id: 1, + slug: 'test', + name: 'test', + products: 'test', + maxProductStage: 'max', + published: false, + updatedAt: new Date(), + createdAt: new Date(), + printingFilesDate, + publishable: false, + description: 'description', + category: category.id, + brand: brand.id, + promoDate, + productsAmount: 1, + }; + const store = createMockStore(initialValues); + + it('should render blank ProjectBrief on /projects/new ', async () => { + const { getByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + + const categoryDropdown = await waitForElement(() => getByTestId('categoryDropdown')); + const brandDropdown = await waitForElement(() => getByTestId('brandDropdown')); + const printingFilesDateField = await waitForElement(() => getByTestId('printingFilesDatePicker')); + const promotionDateField = await waitForElement(() => getByTestId('poromoDateDatePicker')); + const saveButton = await waitForElement(() => getByTestId('projectSaveButton')); + const projectName = await waitForElement(() => getByTestId('projectName')); + const description = await waitForElement(() => getByTestId('description')); + expect(projectName).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + expect(printingFilesDateField).toBeInTheDocument(); + expect(promotionDateField).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + expect(brandDropdown).toBeInTheDocument(); + expect(categoryDropdown).toBeInTheDocument(); + }); + it('should allow change form values', async () => { + const { findByTestId, findByText, findAllByDisplayValue } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const brandDropdown = await waitForElement(() => findByTestId('brandDropdown')); + const categoryDropdown = await waitForElement(() => findByTestId('categoryDropdown')); + const projectName = await waitForElement(() => findByTestId('projectName')); + const description = await waitForElement(() => findByTestId('description')); + const dates = await waitForElement(() => findAllByDisplayValue(format(new Date(), 'yyyy-MM-dd'))); + //CHECK IF DATE CHANGED + fireEvent.change(dates[0], { target: { value: '2011-02-02' } }); + expect(dates[0]).toHaveValue('2011-02-02'); + fireEvent.change(dates[1], { target: { value: '2011-02-03' } }); + expect(dates[1]).toHaveValue('2011-02-03'); + //CHECK IF PROJECT NAME CHANGED + fireEvent.change(projectName, { target: { value: 'projectName' } }); + expect(projectName).toHaveValue('projectName'); + //CHECK IF DESCRIPTION CHANGED + fireEvent.change(description, { target: { value: 'description' } }); + expect(description).toHaveValue('description'); + //CHECK IF BRAND DROPDOWN VALUE CHANGED + const brandValue = await waitForElement(() => findByTestId('brandValue')); + expect(brandValue.innerHTML).toEqual('Marka'); + fireEvent.click(brandDropdown); + const brandItem = await waitForElement(() => findByText(brand.name)); + expect(brandItem.innerHTML).toEqual(brand.name); + fireEvent.click(brandItem); + expect(brandValue.innerHTML).toEqual(brandItem.innerHTML); + //CHECK IF CATEGORY DROPDOWN VALUE CHANGED + const categoryValue = await waitForElement(() => findByTestId('categoryValue')); + expect(categoryValue.innerHTML).toEqual('Kategoria'); + fireEvent.click(categoryDropdown); + const categoryItem = await waitForElement(() => findByText(category.name)); + expect(categoryItem.innerHTML).toEqual(category.name); + fireEvent.click(categoryItem); + expect(categoryValue.innerHTML).toEqual(categoryItem.innerHTML); + }); + it('publish button should not be visible if publishabled is false', async () => { + const { queryByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + expect(queryByTestId('publishButton')).toBe(null); + }); + it('should not submit blank form', async () => { + const { findByTestId, queryAllByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + await act(async () => { + const saveButton = await waitForElement(() => findByTestId('projectSaveButton')); + fireEvent.click(saveButton); + }); + const errors = queryAllByTestId('inputError'); + expect(errors).toHaveLength(5); + }); + it('should save proper form and redirect to /products/${pid}', async () => { + projectApi.createNewProject.mockReturnValueOnce({ + id: 1, + }); + productApi.getProductsByURL.mockReturnValueOnce([ + { + id: 1, + }, + ]); + const { findByTestId, queryAllByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const saveButton = await waitForElement(() => findByTestId('projectSaveButton')); + await act(async () => { + fireEvent.click(saveButton); + const errors = queryAllByTestId('inputError'); + expect(errors).toHaveLength(0); + }); + expect(projectApi.createNewProject).toHaveBeenCalledTimes(1); + expect(productApi.getProductsByURL).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/projects/1/products/1'); + }); + + it('should setCurrentStage to 0', () => { + const spy = jest.spyOn(productActions, 'setCurrentStage'); + renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path: '/projects/new' }, history, store } + ); + expect(spy).toHaveBeenCalledWith(0); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); + +describe('existing project not publishable', () => { + const path = `/projects/1`; + const { history, match, location } = routeConfiguration(path, { id: 1 }); + const initialValues = { + id: 1, + slug: 'test', + name: 'test', + products: 'test', + maxProductStage: 'max', + published: false, + updatedAt: new Date(), + createdAt: new Date(), + printingFilesDate, + publishable: false, + description: 'description', + category: category.id, + brand: brand.id, + promoDate, + productsAmount: 1, + }; + const store = createMockStore(initialValues); + it('should set initial values', async () => { + const { getByTestId, findByDisplayValue } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const projectName = await waitForElement(() => getByTestId('projectName')); + const projectDesc = await waitForElement(() => getByTestId('description')); + const categoryValue = await waitForElement(() => getByTestId('categoryValue')); + const brandValue = await waitForElement(() => getByTestId('brandValue')); + const promoDate = await waitForElement(() => findByDisplayValue(initialValues.printingFilesDate)); + const pfDate = await waitForElement(() => findByDisplayValue(initialValues.promoDate)); + + expect(promoDate).toHaveValue(initialValues.printingFilesDate); + expect(pfDate).toHaveValue(initialValues.promoDate); + expect(categoryValue.innerHTML).toBe(category.name); + expect(brandValue.innerHTML).toBe(brand.name); + expect(projectName).toHaveValue(initialValues.name); + expect(projectDesc).toHaveValue(initialValues.description); + //TODO HOW TO CHECK FORMIK VALUES + }); + it('should expand dropdown', async () => { + const { getByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const brand = await waitForElement(() => getByTestId('brandDropdown')); + const category = await waitForElement(() => getByTestId('categoryDropdown')); + fireEvent.click(brand); + fireEvent.click(category); + + expect(brand).toHaveClass('is-active'); + expect(category).toHaveClass('is-active'); + }); +}); +describe('existing project publishable', () => { + beforeAll(() => { + ReactDOM.createPortal = jest.fn((element, node) => { + return element; + }); + }); + afterEach(() => { + ReactDOM.createPortal.mockClear(); + }); + const path = `/projects/1`; + const { history, match, location } = routeConfiguration(path, { id: 1 }); + const initialValues = { + id: 1, + slug: 'test', + name: 'test', + products: 'test', + maxProductStage: 'max', + published: false, + updatedAt: new Date(), + createdAt: new Date(), + printingFilesDate, + publishable: true, + description: 'description', + category: category.id, + brand: brand.id, + promoDate, + productsAmount: 1, + }; + const store = createMockStore(initialValues); + it('publish button should be in DOM but disabled', async () => { + useCurrentProject.mockReturnValue({ ...initialValues, publishable: false }); + const { findByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const publishButton = await waitForElement(() => findByTestId('publishButton')); + expect(publishButton).toBeInTheDocument(); + expect(publishButton).toBeDisabled(); + }); + it('publish button should be in DOM and not disabled', async () => { + useCurrentProject.mockReturnValue(initialValues); + const { findByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const publishButton = await waitForElement(() => findByTestId('publishButton')); + expect(publishButton).toBeInTheDocument(); + expect(publishButton).not.toBeDisabled(); + }); + it('should open modal and spin spinner', async () => { + projectApi.publishProject.mockReturnValueOnce({ ...initialValues, published: true }); + useCurrentProject.mockReturnValue(initialValues); + productApi.getProductsByURL.mockReturnValueOnce([ + { + id: 1, + }, + ]); + const { findByTestId, findByText } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const publishButton = await waitForElement(() => findByTestId('publishButton')); + expect(publishButton).toBeInTheDocument(); + await act(async () => { + fireEvent.click(publishButton); + }); + const modal = await waitForElement(() => findByText('Publikacja projektu')); + const modalCancel = await waitForElement(() => findByTestId('modalCancel')); + const modalSubmit = await waitForElement(() => findByTestId('modalSubmit')); + expect(modal).toBeInTheDocument(); + expect(modalSubmit).toBeInTheDocument(); + expect(modalCancel).toBeInTheDocument(); + fireEvent.click(modalSubmit); + expect(modalSubmit).toHaveClass('is-loading'); + await wait(() => expect(modalSubmit).not.toHaveClass('is-loading')); + + expect(modal).not.toBeInTheDocument(); + }); + describe('Published Project', () => { + const path = `/projects/1`; + const { history, match, location } = routeConfiguration(path, { id: 1 }); + const initialValues = { + id: 1, + slug: 'test', + name: 'test', + products: 'test', + maxProductStage: 'max', + published: true, + updatedAt: new Date(), + createdAt: new Date(), + printingFilesDate, + publishable: false, + description: 'description', + category: category.id, + brand: brand.id, + promoDate, + productsAmount: 1, + }; + const store = createMockStore(initialValues); + it('should not display publish button when published is true', async () => { + useCurrentProject.mockReturnValue(initialValues); + const { queryByTestId } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + expect(queryByTestId('publishButton')).toBe(null); + }); + it('should have disabled fields', async () => { + useCurrentProject.mockReturnValue(initialValues); + const { findByText, findByTestId, findByDisplayValue } = renderWithProviderAndRoute( + , + { routeOptions: { exact: true, path }, history, store } + ); + const brandValue = await waitForElement(() => findByTestId('brandValue')); + const categoryValue = await waitForElement(() => findByTestId('categoryValue')); + const projectName = await waitForElement(() => findByTestId('projectName')); + const description = await waitForElement(() => findByTestId('description')); + const promo = await waitForElement(() => findByDisplayValue(promoDate)); + const pfDate = await waitForElement(() => findByDisplayValue(printingFilesDate)); + + expect(categoryValue.parentElement).toBeDisabled(); + expect(brandValue.parentElement).toBeDisabled(); + expect(promo).toBeDisabled(); + expect(pfDate).toBeDisabled(); + expect(projectName).toBeDisabled(); + expect(description).toBeDisabled(); + }); + }); +}); diff --git a/evg-outbo-web/src/assets/accepted_file_icon.svg b/evg-outbo-web/src/assets/accepted_file_icon.svg new file mode 100755 index 0000000..6823b77 --- /dev/null +++ b/evg-outbo-web/src/assets/accepted_file_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/evg-outbo-web/src/assets/arrow.svg b/evg-outbo-web/src/assets/arrow.svg new file mode 100755 index 0000000..063eeb0 --- /dev/null +++ b/evg-outbo-web/src/assets/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/arrow_circle.svg b/evg-outbo-web/src/assets/arrow_circle.svg new file mode 100755 index 0000000..a894ad6 --- /dev/null +++ b/evg-outbo-web/src/assets/arrow_circle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/evg-outbo-web/src/assets/blanking_icon.svg b/evg-outbo-web/src/assets/blanking_icon.svg new file mode 100755 index 0000000..a32874d --- /dev/null +++ b/evg-outbo-web/src/assets/blanking_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/evg-outbo-web/src/assets/calendar.svg b/evg-outbo-web/src/assets/calendar.svg new file mode 100755 index 0000000..873d8bd --- /dev/null +++ b/evg-outbo-web/src/assets/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/comment_icon.svg b/evg-outbo-web/src/assets/comment_icon.svg new file mode 100755 index 0000000..f902948 --- /dev/null +++ b/evg-outbo-web/src/assets/comment_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/corrected_file_icon.svg b/evg-outbo-web/src/assets/corrected_file_icon.svg new file mode 100755 index 0000000..0bc669f --- /dev/null +++ b/evg-outbo-web/src/assets/corrected_file_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/evg-outbo-web/src/assets/delete.svg b/evg-outbo-web/src/assets/delete.svg new file mode 100755 index 0000000..05b036f --- /dev/null +++ b/evg-outbo-web/src/assets/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/evg-outbo-web/src/assets/doc.svg b/evg-outbo-web/src/assets/doc.svg new file mode 100755 index 0000000..8ed3509 --- /dev/null +++ b/evg-outbo-web/src/assets/doc.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/done_icon.svg b/evg-outbo-web/src/assets/done_icon.svg new file mode 100755 index 0000000..5ab9ec0 --- /dev/null +++ b/evg-outbo-web/src/assets/done_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/download_icon.svg b/evg-outbo-web/src/assets/download_icon.svg new file mode 100755 index 0000000..1958d1f --- /dev/null +++ b/evg-outbo-web/src/assets/download_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/draft_file_icon.svg b/evg-outbo-web/src/assets/draft_file_icon.svg new file mode 100755 index 0000000..4b5c1e1 --- /dev/null +++ b/evg-outbo-web/src/assets/draft_file_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/evg-outbo-web/src/assets/file_icon.svg b/evg-outbo-web/src/assets/file_icon.svg new file mode 100755 index 0000000..71bd097 --- /dev/null +++ b/evg-outbo-web/src/assets/file_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/evg-outbo-web/src/assets/filter_icon.svg b/evg-outbo-web/src/assets/filter_icon.svg new file mode 100755 index 0000000..bd52ecd --- /dev/null +++ b/evg-outbo-web/src/assets/filter_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/evg-outbo-web/src/assets/icon (1).svg b/evg-outbo-web/src/assets/icon (1).svg new file mode 100755 index 0000000..1958d1f --- /dev/null +++ b/evg-outbo-web/src/assets/icon (1).svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/index.js b/evg-outbo-web/src/assets/index.js new file mode 100755 index 0000000..023e961 --- /dev/null +++ b/evg-outbo-web/src/assets/index.js @@ -0,0 +1,26 @@ +export { ReactComponent as OptionDots } from './menu.svg'; +export { ReactComponent as Caret } from './arrow.svg'; +export { ReactComponent as OutboRocket } from './outbo_rocket.svg'; +export { ReactComponent as OutboLogo } from './outbo_logo.svg'; +export { ReactComponent as PollIcon } from './poll.svg'; +export { ReactComponent as DocIcon } from './doc.svg'; +export { ReactComponent as PuzzelIcon } from './puzzel.svg'; +export { ReactComponent as SearchIcon } from './search.svg'; +export { ReactComponent as CirclePlus } from './plus_circle.svg'; +export { ReactComponent as CalendarIcon } from './calendar.svg'; +export { default as outboLoginBg } from './outbo_login_bg.png'; +export { ReactComponent as LockIcon } from './lock_icon.svg'; +export { ReactComponent as UserIcon } from './user_icon.svg'; +export { ReactComponent as Delete } from './delete.svg'; +export { ReactComponent as BlankingIcon } from './blanking_icon.svg'; +export { ReactComponent as FileIcon } from './file_icon.svg'; +export { ReactComponent as PhotoIcon } from './photo_icon.svg'; +export { ReactComponent as DoneIcon } from './done_icon.svg'; +export { ReactComponent as CommentIcon } from './comment_icon.svg'; +export { ReactComponent as VerticalLine } from './vertical_line.svg'; +export { ReactComponent as CircleArrow } from './arrow_circle.svg'; +export { ReactComponent as CorrectedFileIcon } from './corrected_file_icon.svg'; +export { ReactComponent as AcceptedFileIcon } from './accepted_file_icon.svg'; +export { ReactComponent as DraftFileIcon } from './draft_file_icon.svg'; +export { ReactComponent as DownloadIcon } from './download_icon.svg'; +export { ReactComponent as FilterIcon } from './filter_icon.svg'; diff --git a/evg-outbo-web/src/assets/lock_icon.svg b/evg-outbo-web/src/assets/lock_icon.svg new file mode 100755 index 0000000..88ea8a8 --- /dev/null +++ b/evg-outbo-web/src/assets/lock_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/evg-outbo-web/src/assets/menu.svg b/evg-outbo-web/src/assets/menu.svg new file mode 100755 index 0000000..3dd92b7 --- /dev/null +++ b/evg-outbo-web/src/assets/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/outbo_login_bg.png b/evg-outbo-web/src/assets/outbo_login_bg.png new file mode 100755 index 0000000..ef941b6 Binary files /dev/null and b/evg-outbo-web/src/assets/outbo_login_bg.png differ diff --git a/evg-outbo-web/src/assets/outbo_logo.svg b/evg-outbo-web/src/assets/outbo_logo.svg new file mode 100755 index 0000000..b08117f --- /dev/null +++ b/evg-outbo-web/src/assets/outbo_logo.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/evg-outbo-web/src/assets/outbo_rocket.svg b/evg-outbo-web/src/assets/outbo_rocket.svg new file mode 100755 index 0000000..b5440ad --- /dev/null +++ b/evg-outbo-web/src/assets/outbo_rocket.svg @@ -0,0 +1 @@ +outbo_rocket \ No newline at end of file diff --git a/evg-outbo-web/src/assets/photo_icon.svg b/evg-outbo-web/src/assets/photo_icon.svg new file mode 100755 index 0000000..3757fec --- /dev/null +++ b/evg-outbo-web/src/assets/photo_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/evg-outbo-web/src/assets/plus_circle.svg b/evg-outbo-web/src/assets/plus_circle.svg new file mode 100755 index 0000000..42cf5b3 --- /dev/null +++ b/evg-outbo-web/src/assets/plus_circle.svg @@ -0,0 +1,6 @@ + + + + + + + diff --git a/evg-outbo-web/src/assets/poll.svg b/evg-outbo-web/src/assets/poll.svg new file mode 100755 index 0000000..bf712b5 --- /dev/null +++ b/evg-outbo-web/src/assets/poll.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/puzzel.svg b/evg-outbo-web/src/assets/puzzel.svg new file mode 100755 index 0000000..c3909b9 --- /dev/null +++ b/evg-outbo-web/src/assets/puzzel.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/search.svg b/evg-outbo-web/src/assets/search.svg new file mode 100755 index 0000000..eb0d208 --- /dev/null +++ b/evg-outbo-web/src/assets/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/assets/user_icon.svg b/evg-outbo-web/src/assets/user_icon.svg new file mode 100755 index 0000000..f371023 --- /dev/null +++ b/evg-outbo-web/src/assets/user_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/evg-outbo-web/src/assets/vertical_line.svg b/evg-outbo-web/src/assets/vertical_line.svg new file mode 100755 index 0000000..4113312 --- /dev/null +++ b/evg-outbo-web/src/assets/vertical_line.svg @@ -0,0 +1,3 @@ + + + diff --git a/evg-outbo-web/src/atoms/BriefHeader.tsx b/evg-outbo-web/src/atoms/BriefHeader.tsx new file mode 100755 index 0000000..47d38eb --- /dev/null +++ b/evg-outbo-web/src/atoms/BriefHeader.tsx @@ -0,0 +1,8 @@ +import React, { HTMLAttributes } from 'react'; +import cx from 'classnames'; + +const BriefHeader: React.FC> = ({ className, ...props }) => { + return ; +}; + +export default BriefHeader; diff --git a/evg-outbo-web/src/atoms/ButtonCheckbox.tsx b/evg-outbo-web/src/atoms/ButtonCheckbox.tsx new file mode 100755 index 0000000..e2e3f1b --- /dev/null +++ b/evg-outbo-web/src/atoms/ButtonCheckbox.tsx @@ -0,0 +1,21 @@ +import React, { InputHTMLAttributes } from 'react'; +import cx from 'classnames'; + +const ButtonCheckbox: React.FC> = ({ children, className, ...props }) => ( +
+ + +
+); + +export default ButtonCheckbox; diff --git a/evg-outbo-web/src/atoms/Checkbox.tsx b/evg-outbo-web/src/atoms/Checkbox.tsx new file mode 100755 index 0000000..88e38d7 --- /dev/null +++ b/evg-outbo-web/src/atoms/Checkbox.tsx @@ -0,0 +1,11 @@ +import React, { InputHTMLAttributes } from 'react'; +import cx from 'classnames'; + +const Checkbox: React.FC> = ({ className, children, ...props }) => ( + <> + + + +); + +export default Checkbox; diff --git a/evg-outbo-web/src/atoms/ClimbingLoader.tsx b/evg-outbo-web/src/atoms/ClimbingLoader.tsx new file mode 100755 index 0000000..e87d951 --- /dev/null +++ b/evg-outbo-web/src/atoms/ClimbingLoader.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { ClimbingBoxLoader } from 'react-spinners'; + +export const ClimbingLoader = (): JSX.Element => { + return ( +
+
+
+ +
+
+
+ ); +}; diff --git a/evg-outbo-web/src/atoms/Datepicker.tsx b/evg-outbo-web/src/atoms/Datepicker.tsx new file mode 100755 index 0000000..3de5594 --- /dev/null +++ b/evg-outbo-web/src/atoms/Datepicker.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import DatePicker from 'react-datepicker'; +import cx from 'classnames'; +import 'react-datepicker/dist/react-datepicker.css'; +import { CalendarIcon } from 'assets'; +import { parse } from 'date-fns'; +export type DatePickerChangeHandler = ( + date: Date | null, + event: React.SyntheticEvent | undefined +) => void; + +interface DatePickerProps { + onChange: DatePickerChangeHandler; + selected: string; + disabled: boolean; + dataTestid?: string; + title?: string; +} + +const Datepicker: React.FC = ({ onChange, selected, disabled, dataTestid, title }) => { + return ( +
+ + +
+ ); +}; + +export default Datepicker; diff --git a/evg-outbo-web/src/atoms/Dropdown.tsx b/evg-outbo-web/src/atoms/Dropdown.tsx new file mode 100755 index 0000000..f5d485e --- /dev/null +++ b/evg-outbo-web/src/atoms/Dropdown.tsx @@ -0,0 +1,160 @@ +import React, { useState, useCallback, useRef, useContext, useEffect } from 'react'; +import { Caret } from '../assets/index'; +import cx from 'classnames'; +import useOnClickOutside from 'use-onclickoutside'; + +export type CurrentValue = { + value: number; + label: string; +}; + +type ContextValues = { + open: boolean; + disabled: boolean; + fullwidth: boolean; + current: CurrentValue | undefined; + onChange: (value: CurrentValue) => void; + toggleOpen: () => void; +}; + +type ItemProps = { + label: string; + value: number; +}; + +const DropdownContext = React.createContext({ + open: false, + fullwidth: false, + disabled: false, + current: undefined, + onChange: () => {}, + toggleOpen: () => {}, +}); + +const useDropdownContext = (): ContextValues => { + const context = useContext(DropdownContext); + if (!context) { + throw new Error(`NOPE`); + } + return context; +}; + +const Item: React.FC = ({ label, value }) => { + const { onChange } = useDropdownContext(); + return ( + onChange({ label, value })}> + {label} + + ); +}; + +const Label: React.FC<{ valueDataTestid?: string }> = ({ children, valueDataTestid }) => { + const { current, fullwidth, disabled } = useDropdownContext(); + return ( +
+ +
+ ); +}; + +const Menu: React.FC = ({ children }) => { + return ( + + ); +}; + +type CompoundComponents = { + Item: React.FC; + Menu: React.FC; + Label: React.FC<{ valueDataTestid?: string }>; +}; + +type DropdownProps = { + fullwidth: boolean; + disabled: boolean; + onChange: (value: number) => void; + initial?: CurrentValue; + dataTestid?: string; + title?: string; +}; + +const Dropdown: React.FC & CompoundComponents = ({ + onChange, + title, + disabled, + fullwidth, + children, + initial, + dataTestid, +}) => { + const [current, setCurrent] = useState(initial); + const [open, setOpen] = useState(false); + + useEffect(() => { + setCurrent(initial); + }, [initial]); + + const handleChange = useCallback( + ({ value, label }) => { + if (!disabled) { + setCurrent({ value, label }); + onChange && onChange(value); + } + }, + [disabled, onChange] + ); + + const toggleOpen = useCallback(() => { + if (!disabled) { + setOpen((open) => !open); + } + }, [disabled]); + + const node = useRef(null); + + useOnClickOutside(node, () => setOpen(false)); + + return ( + +
+ {children} +
+
+ ); +}; + +Dropdown.Item = Item; +Dropdown.Menu = Menu; +Dropdown.Label = Label; + +export default Dropdown; diff --git a/evg-outbo-web/src/atoms/FieldInputError.tsx b/evg-outbo-web/src/atoms/FieldInputError.tsx new file mode 100755 index 0000000..acc9408 --- /dev/null +++ b/evg-outbo-web/src/atoms/FieldInputError.tsx @@ -0,0 +1,8 @@ +import React, { HTMLAttributes } from 'react'; +import cx from 'classnames'; + +const FieldInputError: React.FC> = ({ className, ...props }) => { + return
; +}; + +export default FieldInputError; diff --git a/evg-outbo-web/src/atoms/HorizontalLine.tsx b/evg-outbo-web/src/atoms/HorizontalLine.tsx new file mode 100755 index 0000000..ec1b5a6 --- /dev/null +++ b/evg-outbo-web/src/atoms/HorizontalLine.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function HorizontalLine(): JSX.Element { + return
; +} + +export default HorizontalLine; diff --git a/evg-outbo-web/src/atoms/ModalFooter.tsx b/evg-outbo-web/src/atoms/ModalFooter.tsx new file mode 100755 index 0000000..95df3a8 --- /dev/null +++ b/evg-outbo-web/src/atoms/ModalFooter.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import cx from 'classnames'; +type ModalFooterProps = { + action: any; + text: string; + cancelAction: () => void; + isSubmitting: boolean; +}; + +export const ModalFooter: React.FC = ({ action, text, cancelAction, isSubmitting }) => { + return ( + <> + + + + ); +}; diff --git a/evg-outbo-web/src/atoms/SearchInput.tsx b/evg-outbo-web/src/atoms/SearchInput.tsx new file mode 100755 index 0000000..b94fc32 --- /dev/null +++ b/evg-outbo-web/src/atoms/SearchInput.tsx @@ -0,0 +1,31 @@ +import React, { InputHTMLAttributes } from 'react'; +import cx from 'classnames'; +import { FieldTextInputInterface } from 'interfaces/InputInterfaces'; +import debounce from 'lodash.debounce'; + +interface SearchInput { + searchFunc: (searchValue: string) => void; +} + +const SearchInput: React.FC> = ({ + field, + form, + searchFunc, + className, + ...props +}) => { + return ( + { + form.handleChange(e); + searchFunc(e.target.value); + }} + /> + ); +}; + +export default SearchInput; diff --git a/evg-outbo-web/src/atoms/Select.tsx b/evg-outbo-web/src/atoms/Select.tsx new file mode 100755 index 0000000..45235a9 --- /dev/null +++ b/evg-outbo-web/src/atoms/Select.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { FieldProps } from 'formik'; + +type SelectProps = { + disabled: boolean; + dataTestid?: string; + title?: string; +}; + +const Select: React.FC = ({ field, form, children, dataTestid, title, ...props }) => { + return ( +
+
+ Ilość produktów: +
+
+
+ +
+
+
+ ); +}; + +export default Select; diff --git a/evg-outbo-web/src/atoms/StageBarLink.tsx b/evg-outbo-web/src/atoms/StageBarLink.tsx new file mode 100755 index 0000000..97855ef --- /dev/null +++ b/evg-outbo-web/src/atoms/StageBarLink.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; +import { useHistory } from 'react-router'; +import useLocationParams from '../hooks/useLocationParams'; + +type StageBarLinkProps = { + target: string; + currentStage: number; + group: number; + index: number; +}; + +export const StageBarLink: React.FC = ({ target, index, currentStage, group }) => { + const disabled = useMemo(() => group < index, [group, index]); + const active = useMemo(() => currentStage === index, [currentStage, index]); + const history = useHistory(); + const [params, location] = useLocationParams(); + return ( +
history.push({ pathname: target, search: params.toString() }) : (): void => {}} + className={cx('stage-bar__box', { + 'stage-bar__box--active': active, + })} + > + + {index} + +
+ ); +}; diff --git a/evg-outbo-web/src/atoms/TextArea.tsx b/evg-outbo-web/src/atoms/TextArea.tsx new file mode 100755 index 0000000..e69467d --- /dev/null +++ b/evg-outbo-web/src/atoms/TextArea.tsx @@ -0,0 +1,14 @@ +import React, { TextareaHTMLAttributes } from 'react'; +import cx from 'classnames'; + +const TextArea: React.FC> = ({ className, ...props }) => { + return ( +