diff --git a/.deploy/__init__.py b/.deploy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.deploy/local/__init__.py b/.deploy/local/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.deploy/local/api/Dockerfile b/.deploy/local/api/Dockerfile new file mode 100644 index 0000000..008a4dd --- /dev/null +++ b/.deploy/local/api/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.7 +ENV PYTHONUNBUFFERED 1 +RUN mkdir /app +WORKDIR /app +COPY . /app/ +RUN pip install --upgrade pip +RUN if [ ${MODE} = prod ]; \ + then pip install -r requirements/prod.txt; \ + else pip install -r requirements/dev.txt; \ + fi + diff --git a/.deploy/local/api/__init__.py b/.deploy/local/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.deploy/local/nginx/Dockerfile b/.deploy/local/nginx/Dockerfile new file mode 100644 index 0000000..99b07a9 --- /dev/null +++ b/.deploy/local/nginx/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.17-alpine + +RUN rm /etc/nginx/conf.d/default.conf +COPY default.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/.deploy/local/nginx/__init__.py b/.deploy/local/nginx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.deploy/local/nginx/default.conf b/.deploy/local/nginx/default.conf new file mode 100644 index 0000000..57e5f4f --- /dev/null +++ b/.deploy/local/nginx/default.conf @@ -0,0 +1,17 @@ +upstream localhost { + server web_service:8000; +} + +server { + listen 8000; + server_name localhost; + + location /static/ { + autoindex on; + alias /app/static/; + } + + location / { + proxy_pass http://localhost; + } +} diff --git a/.deploy/local/postgres/__init__.py b/.deploy/local/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.deploy/local/postgres/init.sql b/.deploy/local/postgres/init.sql new file mode 100755 index 0000000..93c3d06 --- /dev/null +++ b/.deploy/local/postgres/init.sql @@ -0,0 +1,626 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 10.9 (Ubuntu 10.9-0ubuntu0.18.04.1) +-- Dumped by pg_dump version 10.9 (Ubuntu 10.9-0ubuntu0.18.04.1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: +-- + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; + + +-- +-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; + + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Name: alembic_version; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.alembic_version ( + version_num character varying(32) NOT NULL +); + + +ALTER TABLE public.alembic_version OWNER TO task_office_user; + +-- +-- Name: boards; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.boards ( + id integer NOT NULL, + uuid uuid NOT NULL, + meta json, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + name character varying(80) NOT NULL, + description character varying(255), + owner_uuid uuid NOT NULL, + is_active boolean +); + + +ALTER TABLE public.boards OWNER TO task_office_user; + +-- +-- Name: boards_id_seq; Type: SEQUENCE; Schema: public; Owner: task_office_user +-- + +CREATE SEQUENCE public.boards_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.boards_id_seq OWNER TO task_office_user; + +-- +-- Name: boards_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: task_office_user +-- + +ALTER SEQUENCE public.boards_id_seq OWNED BY public.boards.id; + + +-- +-- Name: columns; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.columns ( + id integer NOT NULL, + uuid uuid NOT NULL, + meta json, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + name character varying(120) NOT NULL, + "position" integer, + board_uuid uuid NOT NULL +); + + +ALTER TABLE public.columns OWNER TO task_office_user; + +-- +-- Name: columns_id_seq; Type: SEQUENCE; Schema: public; Owner: task_office_user +-- + +CREATE SEQUENCE public.columns_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.columns_id_seq OWNER TO task_office_user; + +-- +-- Name: columns_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: task_office_user +-- + +ALTER SEQUENCE public.columns_id_seq OWNED BY public.columns.id; + + +-- +-- Name: permissions; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.permissions ( + id integer NOT NULL, + uuid uuid NOT NULL, + meta json, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + role integer, + user_uuid uuid NOT NULL, + board_uuid uuid NOT NULL +); + + +ALTER TABLE public.permissions OWNER TO task_office_user; + +-- +-- Name: permissions_id_seq; Type: SEQUENCE; Schema: public; Owner: task_office_user +-- + +CREATE SEQUENCE public.permissions_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.permissions_id_seq OWNER TO task_office_user; + +-- +-- Name: permissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: task_office_user +-- + +ALTER SEQUENCE public.permissions_id_seq OWNED BY public.permissions.id; + + +-- +-- Name: tasks; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.tasks ( + id integer NOT NULL, + uuid uuid NOT NULL, + meta json, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + expire_at timestamp without time zone, + label character varying(80), + name character varying(120) NOT NULL, + description character varying(120) NOT NULL, + state integer, + "position" integer, + column_uuid uuid NOT NULL, + creator_uuid uuid NOT NULL +); + + +ALTER TABLE public.tasks OWNER TO task_office_user; + +-- +-- Name: tasks_id_seq; Type: SEQUENCE; Schema: public; Owner: task_office_user +-- + +CREATE SEQUENCE public.tasks_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.tasks_id_seq OWNER TO task_office_user; + +-- +-- Name: tasks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: task_office_user +-- + +ALTER SEQUENCE public.tasks_id_seq OWNED BY public.tasks.id; + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.users ( + id integer NOT NULL, + uuid uuid NOT NULL, + meta json, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + username character varying(80) NOT NULL, + email character varying(255) NOT NULL, + bio character varying(300), + phone character varying(300), + password bytea, + is_active boolean, + is_superuser boolean +); + + +ALTER TABLE public.users OWNER TO task_office_user; + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: task_office_user +-- + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.users_id_seq OWNER TO task_office_user; + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: task_office_user +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + +-- +-- Name: users_tasks; Type: TABLE; Schema: public; Owner: task_office_user +-- + +CREATE TABLE public.users_tasks ( + task_uuid uuid NOT NULL, + user_uuid uuid NOT NULL +); + + +ALTER TABLE public.users_tasks OWNER TO task_office_user; + +-- +-- Name: boards id; Type: DEFAULT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.boards ALTER COLUMN id SET DEFAULT nextval('public.boards_id_seq'::regclass); + + +-- +-- Name: columns id; Type: DEFAULT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.columns ALTER COLUMN id SET DEFAULT nextval('public.columns_id_seq'::regclass); + + +-- +-- Name: permissions id; Type: DEFAULT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions ALTER COLUMN id SET DEFAULT nextval('public.permissions_id_seq'::regclass); + + +-- +-- Name: tasks id; Type: DEFAULT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + +-- +-- Data for Name: alembic_version; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.alembic_version (version_num) FROM stdin; +d99344769ddd +\. + + +-- +-- Data for Name: boards; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.boards (id, uuid, meta, created_at, updated_at, name, description, owner_uuid, is_active) FROM stdin; +1 74cb6568-2c5c-437b-a8e5-a226443f220c {} 2020-04-04 19:37:13.167729 2020-04-04 19:37:13.167734 Board #1 name Board #1 description 2ce70298-e3d9-47aa-a58e-ce55d9c351fd t +2 f46f0533-f94b-40ab-aa4d-286bb433d79d {} 2020-04-04 19:37:24.722797 2020-04-04 19:37:24.722803 Board #2 name Board #2 description 2ce70298-e3d9-47aa-a58e-ce55d9c351fd t +3 3dd6f7b8-d72a-4da8-a94f-5f665577c2bb {} 2020-04-04 19:37:33.883561 2020-04-04 19:37:33.883567 Board #3 name Board #3 description 2ce70298-e3d9-47aa-a58e-ce55d9c351fd t +\. + + +-- +-- Data for Name: columns; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.columns (id, uuid, meta, created_at, updated_at, name, "position", board_uuid) FROM stdin; +4 4223251e-6a4d-4be5-a4db-632381dcc18e {} 2020-04-04 19:42:45.543069 2020-04-04 19:42:45.543076 High priority 1 74cb6568-2c5c-437b-a8e5-a226443f220c +1 09a57486-cb0f-4981-bbfe-880161810aee {} 2020-04-04 19:40:47.659333 2020-04-04 19:42:45.553613 Done 4 74cb6568-2c5c-437b-a8e5-a226443f220c +3 e55ff02a-ca1b-4f17-998c-b606e5c295b4 {} 2020-04-04 19:42:35.497317 2020-04-04 19:42:45.553613 Low priority 2 74cb6568-2c5c-437b-a8e5-a226443f220c +2 a793e93e-ed03-47f5-995c-cf13dada93c0 {} 2020-04-04 19:41:18.115626 2020-04-04 19:42:45.553613 Rejected tasks 3 74cb6568-2c5c-437b-a8e5-a226443f220c +6 74a9c56e-c44b-4d1d-bb9c-378c3e580d2c {} 2020-04-04 19:47:28.867338 2020-04-04 19:47:28.867344 Column name2 1 f46f0533-f94b-40ab-aa4d-286bb433d79d +5 e806769c-746a-40d2-8666-f970acac1455 {} 2020-04-04 19:47:23.347919 2020-04-04 19:47:28.874041 Column name1 2 f46f0533-f94b-40ab-aa4d-286bb433d79d +\. + + +-- +-- Data for Name: permissions; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.permissions (id, uuid, meta, created_at, updated_at, role, user_uuid, board_uuid) FROM stdin; +1 5220680b-33d4-4973-b35a-576a98ef5a88 {} 2020-04-04 19:37:13.177124 2020-04-04 19:37:13.17713 1 2ce70298-e3d9-47aa-a58e-ce55d9c351fd 74cb6568-2c5c-437b-a8e5-a226443f220c +2 46ee1a2b-4500-4c74-a7a7-2a4ec4884778 {} 2020-04-04 19:37:24.72541 2020-04-04 19:37:24.725415 1 2ce70298-e3d9-47aa-a58e-ce55d9c351fd f46f0533-f94b-40ab-aa4d-286bb433d79d +3 bdc8b9fc-ecca-4101-8baf-8682883b8eb4 {} 2020-04-04 19:37:33.886127 2020-04-04 19:37:33.886132 1 2ce70298-e3d9-47aa-a58e-ce55d9c351fd 3dd6f7b8-d72a-4da8-a94f-5f665577c2bb +\. + + +-- +-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.tasks (id, uuid, meta, created_at, updated_at, expire_at, label, name, description, state, "position", column_uuid, creator_uuid) FROM stdin; +2 61fdc381-9c8b-46b7-9e98-dd04ecbf1103 {} 2020-04-04 19:46:03.883278 2020-04-04 19:46:03.883284 2020-05-25 05:30:11 Label1 Task #2 name Task #2 description 1 1 4223251e-6a4d-4be5-a4db-632381dcc18e 2ce70298-e3d9-47aa-a58e-ce55d9c351fd +1 89771883-849f-4aef-930c-285f0131ae56 {} 2020-04-04 19:44:47.12337 2020-04-04 19:46:03.887337 2020-05-25 05:30:11 Label1 Task #1 name Task #1 description 1 2 4223251e-6a4d-4be5-a4db-632381dcc18e 2ce70298-e3d9-47aa-a58e-ce55d9c351fd +3 a4a9594b-7b12-4b9c-b892-d48d666f711c {} 2020-04-04 19:49:44.812372 2020-04-04 19:49:44.812383 2021-05-25 05:30:11 Label1 Task ##2 name Task ##2 description 1 1 74a9c56e-c44b-4d1d-bb9c-378c3e580d2c 2ce70298-e3d9-47aa-a58e-ce55d9c351fd +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.users (id, uuid, meta, created_at, updated_at, username, email, bio, phone, password, is_active, is_superuser) FROM stdin; +1 2ce70298-e3d9-47aa-a58e-ce55d9c351fd {} 2020-04-04 19:35:33.864782 2020-04-04 19:35:33.86479 Amigo amigo@gmaill.com \N \N \\x243262243132246741684f36616f4a744d7350364d745737426d43637557616477596d3837617578447a31354e7a4c36653856356953595961683853 t f +2 df362410-c706-4ad8-9009-98eee9ff4a82 {} 2020-04-04 19:36:14.527745 2020-04-04 19:36:14.527751 Sergio sergio@gmaill.com \N \N \\x2432622431322443423036463073594363497750682e7354323641766542664852414f6e58594a4d623837326a594b305364374151315576456b4c6d t f +\. + + +-- +-- Data for Name: users_tasks; Type: TABLE DATA; Schema: public; Owner: task_office_user +-- + +COPY public.users_tasks (task_uuid, user_uuid) FROM stdin; +61fdc381-9c8b-46b7-9e98-dd04ecbf1103 df362410-c706-4ad8-9009-98eee9ff4a82 +a4a9594b-7b12-4b9c-b892-d48d666f711c 2ce70298-e3d9-47aa-a58e-ce55d9c351fd +a4a9594b-7b12-4b9c-b892-d48d666f711c df362410-c706-4ad8-9009-98eee9ff4a82 +\. + + +-- +-- Name: boards_id_seq; Type: SEQUENCE SET; Schema: public; Owner: task_office_user +-- + +SELECT pg_catalog.setval('public.boards_id_seq', 3, true); + + +-- +-- Name: columns_id_seq; Type: SEQUENCE SET; Schema: public; Owner: task_office_user +-- + +SELECT pg_catalog.setval('public.columns_id_seq', 6, true); + + +-- +-- Name: permissions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: task_office_user +-- + +SELECT pg_catalog.setval('public.permissions_id_seq', 3, true); + + +-- +-- Name: tasks_id_seq; Type: SEQUENCE SET; Schema: public; Owner: task_office_user +-- + +SELECT pg_catalog.setval('public.tasks_id_seq', 3, true); + + +-- +-- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: task_office_user +-- + +SELECT pg_catalog.setval('public.users_id_seq', 2, true); + + +-- +-- Name: alembic_version alembic_version_pkc; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.alembic_version + ADD CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num); + + +-- +-- Name: boards boards_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.boards + ADD CONSTRAINT boards_pkey PRIMARY KEY (id, uuid); + + +-- +-- Name: boards boards_uuid_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.boards + ADD CONSTRAINT boards_uuid_key UNIQUE (uuid); + + +-- +-- Name: columns columns_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.columns + ADD CONSTRAINT columns_pkey PRIMARY KEY (id, uuid); + + +-- +-- Name: columns columns_uuid_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.columns + ADD CONSTRAINT columns_uuid_key UNIQUE (uuid); + + +-- +-- Name: permissions permissions_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions + ADD CONSTRAINT permissions_pkey PRIMARY KEY (id, uuid); + + +-- +-- Name: permissions permissions_uuid_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions + ADD CONSTRAINT permissions_uuid_key UNIQUE (uuid); + + +-- +-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_pkey PRIMARY KEY (id, uuid); + + +-- +-- Name: tasks tasks_uuid_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_uuid_key UNIQUE (uuid); + + +-- +-- Name: columns unique_board__board_column_name; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.columns + ADD CONSTRAINT unique_board__board_column_name UNIQUE (board_uuid, name); + + +-- +-- Name: permissions unique_board_owner_permission; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions + ADD CONSTRAINT unique_board_owner_permission UNIQUE (board_uuid, user_uuid); + + +-- +-- Name: boards unique_name_owner_board; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.boards + ADD CONSTRAINT unique_name_owner_board UNIQUE (name, owner_uuid); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id, uuid); + + +-- +-- Name: users_tasks users_tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users_tasks + ADD CONSTRAINT users_tasks_pkey PRIMARY KEY (task_uuid, user_uuid); + + +-- +-- Name: users users_username_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_username_key UNIQUE (username); + + +-- +-- Name: users users_uuid_key; Type: CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_uuid_key UNIQUE (uuid); + + +-- +-- Name: ix_boards_description; Type: INDEX; Schema: public; Owner: task_office_user +-- + +CREATE INDEX ix_boards_description ON public.boards USING btree (description); + + +-- +-- Name: ix_users_email; Type: INDEX; Schema: public; Owner: task_office_user +-- + +CREATE UNIQUE INDEX ix_users_email ON public.users USING btree (email); + + +-- +-- Name: boards boards_owner_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.boards + ADD CONSTRAINT boards_owner_uuid_fkey FOREIGN KEY (owner_uuid) REFERENCES public.users(uuid); + + +-- +-- Name: columns columns_board_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.columns + ADD CONSTRAINT columns_board_uuid_fkey FOREIGN KEY (board_uuid) REFERENCES public.boards(uuid); + + +-- +-- Name: permissions permissions_board_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions + ADD CONSTRAINT permissions_board_uuid_fkey FOREIGN KEY (board_uuid) REFERENCES public.boards(uuid); + + +-- +-- Name: permissions permissions_user_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.permissions + ADD CONSTRAINT permissions_user_uuid_fkey FOREIGN KEY (user_uuid) REFERENCES public.users(uuid); + + +-- +-- Name: tasks tasks_column_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_column_uuid_fkey FOREIGN KEY (column_uuid) REFERENCES public.columns(uuid); + + +-- +-- Name: tasks tasks_creator_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_creator_uuid_fkey FOREIGN KEY (creator_uuid) REFERENCES public.users(uuid); + + +-- +-- Name: users_tasks users_tasks_task_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users_tasks + ADD CONSTRAINT users_tasks_task_uuid_fkey FOREIGN KEY (task_uuid) REFERENCES public.tasks(uuid); + + +-- +-- Name: users_tasks users_tasks_user_uuid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: task_office_user +-- + +ALTER TABLE ONLY public.users_tasks + ADD CONSTRAINT users_tasks_user_uuid_fkey FOREIGN KEY (user_uuid) REFERENCES public.users(uuid); + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1168e3a --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# General +# ------------------------------------------------------------------------------ +FLASK_APP=wsgi.py +FLASK_READ_DOT_ENV_FILE=True +FLASK_DEBUG=0 +MODE=dev +USE_DOCS=True +CORS_ORIGIN_WHITELIST=* +PROJECT_NAME=Task-Office +FLASK_SECRET=eBH93Dcn4_Pm0ryYDaGa3bCo-ZYvMabuUF6xPefYKe-lvTYp53IrTsjQZ12Lga2Bdfjt7cFmcIAB-RZapRmmzQ + +# JWT +# ------------------------------------------------------------------------------ +JWT_ACCESS_TOKEN_EXPIRES=30 +JWT_REFRESH_TOKEN_EXPIRES=7 + + +# DB +# ------------------------------------------------------------------------------ +POSTGRES_HOST=postgres_service +POSTGRES_PORT=5432 +POSTGRES_DB=task_office_dev +POSTGRES_USER=task_office_user +POSTGRES_PASSWORD=task_office_user + +# Cache +# ------------------------------------------------------------------------------ +CACHE_TYPE=redis +CACHE_REDIS_HOST=redis_service +CACHE_REDIS_PORT=6379 +CACHE_REDIS_PASSWORD=redis111 +CACHE_REDIS_DB=0 +CACHE_DEFAULT_TIMEOUT=900 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8dd3ca0..fb70ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ # C extensions *.so -.idea/* +.idea* +.env + # Local development ignores venv data.sqlite diff --git a/README.rst b/README.rst index 2ecae64..2764307 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,73 @@ =============================== Task Office =============================== +Task Office - pet app with using Flask -Features --------- -* TODO +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black + :alt: Black code style + + +Run Task Office +^^^^^^^^^^^^^^^^^^ + +To run app local use:: + + $ cd + $ cp .env.example .env + + # to run app perform: + # install, run postgres, redis, actualize .env + $ flask run --with-threads + # type http://127.0.0.1:5000/ in browser + + # !!! run tests only with docker-compose!!! + +To run app with docker use:: + + # to run app perform:: + # install Docker, Docker Compose + # https://docs.docker.com/v17.12/install/ + # https://docs.docker.com/compose/install/ + $ cd + $ docker-compose up --build + # type http://127.0.0.1:80/ in browser + + # test user credentials:: + # username: amigo@gmaill.com + # password: amigo1111 + + # run tests: + $ docker-compose -f docker-compose-tests.yml up + $ docker-compose -f docker-compose-tests.yml rm -fsv + + +API docs url:: + + /api/v1/docs + +Run the following commands to create your app's +database tables and perform the initial migration :: + + flask db init + flask db migrate + flask db upgrade + +Translations commands:: + + https://pythonhosted.org/Flask-Babel/ + + # Create(if not exists) map of translations + pybabel extract -F babel.cfg -k lazy_gettext -o translations/messages.pot . + + # Init translations(if not exists) + pybabel init -i messages.pot -d translations -l uk + pybabel init -i messages.pot -d translations -l ru + + # Update map and translations + pybabel update -i translations/messages.pot -d translations + + # Compile translations + pybabel compile -d translations + diff --git a/TODO.rst b/TODO.rst new file mode 100644 index 0000000..8cdfebc --- /dev/null +++ b/TODO.rst @@ -0,0 +1,23 @@ +================= +TODO List: +================= + +Issues General +^^^^^^^^^^^^^^ +* Try to handle all errors and wrap them by our custom error class(InvalidUsage), with using translations +* Implement auto-generated api +* Improve: app config, docker-compose-test.yaml, app config for test mode +* Speed up tests duration + +Issues By Features +^^^^^^^^^^^^^^^^^^ +Columns +------- +* If column will removing reset columns ordering in board + +Tasks +----- +* !!Done!! Check is unique task name for current Board in post, put +* If task will changing column reset tasks ordering in new and ald columns +* In ../tasks/by-columns implements search by tasks fields +* In tasks[post, put] validate is performers applied to board diff --git a/app.py b/app.py deleted file mode 100644 index 4b059ec..0000000 --- a/app.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import Flask - -app = Flask(__name__) - - -@app.route('/') -def hello_world(): - return 'Hello World!' - - -if __name__ == '__main__': - app.run() diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..0cc0bac --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.autoescape,jinja2.ext.with_ \ No newline at end of file diff --git a/docker-compose-tests.yml b/docker-compose-tests.yml new file mode 100644 index 0000000..a541457 --- /dev/null +++ b/docker-compose-tests.yml @@ -0,0 +1,35 @@ +version: '3' + +services: + + web_service: + container_name: web_1_test + env_file: + - .env + environment: + MODE: "test" + build: + context: ./ + dockerfile: .deploy/local/api/Dockerfile + command: > + bash -c "flask db upgrade && pytest -s -v --cov=task_office tests/" + volumes: + - ./:/app + expose: + - 8000 + depends_on: + - postgres_service + - redis_service + + postgres_service: + container_name: postgres_1_test + image: postgres + env_file: + - .env + + redis_service: + container_name: redis_1_test + image: redis + command: redis-server --requirepass ${CACHE_REDIS_PASSWORD} + env_file: + - .env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5fcd4c2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3' + +services: + nginx_service: + image: nginx + container_name: nginx_1 + ports: + - 80:8000 + volumes: + - ./:/app + - .deploy/local/nginx/default.conf:/etc/nginx/conf.d/default.conf + - static_volume:/app/static + - templates_volume:/app/templates + expose: + - 80 + depends_on: + - web_service + + web_service: + container_name: web_1 + restart: always + env_file: + - .env + build: + context: ./ + dockerfile: .deploy/local/api/Dockerfile + command: > + bash -c "flask db upgrade && chmod 755 -R /app/static && gunicorn -w 3 -b 0.0.0.0:8000 wsgi:app" + volumes: + - ./:/app + - static_volume:/app/static + - templates_volume:/app/templates + expose: + - 8000 + depends_on: + - postgres_service + - redis_service + + postgres_service: + container_name: postgres_1 + image: postgres + env_file: + - .env + volumes: + - postgres_volume:/var/lib/postgresql/data/ + - .deploy/local/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + + redis_service: + container_name: redis_1 + image: redis + command: redis-server --requirepass ${CACHE_REDIS_PASSWORD} + env_file: + - .env + +volumes: + postgres_volume: + driver: local + static_volume: + templates_volume: diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..d766af8 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger("alembic.env") + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app + +config.set_main_option( + "sqlalchemy.url", + current_app.config.get("SQLALCHEMY_DATABASE_URI").replace("%", "%%"), +) +target_metadata = current_app.extensions["migrate"].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/d99344769ddd_.py b/migrations/versions/d99344769ddd_.py new file mode 100644 index 0000000..df0a1ee --- /dev/null +++ b/migrations/versions/d99344769ddd_.py @@ -0,0 +1,135 @@ +"""empty message + +Revision ID: d99344769ddd +Revises: +Create Date: 2020-03-28 18:44:56.147877 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d99344769ddd" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", postgresql.UUID(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("username", sa.String(length=80), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("bio", sa.String(length=300), nullable=True), + sa.Column("phone", sa.String(length=300), nullable=True), + sa.Column("password", sa.Binary(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("is_superuser", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id", "uuid"), + sa.UniqueConstraint("username"), + sa.UniqueConstraint("uuid"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "boards", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", postgresql.UUID(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("owner_uuid", postgresql.UUID(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(["owner_uuid"], ["users.uuid"]), + sa.PrimaryKeyConstraint("id", "uuid"), + sa.UniqueConstraint("name", "owner_uuid", name="unique_name_owner_board"), + sa.UniqueConstraint("uuid"), + ) + op.create_index( + op.f("ix_boards_description"), "boards", ["description"], unique=False + ) + op.create_table( + "columns", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", postgresql.UUID(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("position", sa.Integer(), nullable=True), + sa.Column("board_uuid", postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(["board_uuid"], ["boards.uuid"]), + sa.PrimaryKeyConstraint("id", "uuid"), + sa.UniqueConstraint( + "board_uuid", "name", name="unique_board__board_column_name" + ), + sa.UniqueConstraint("uuid"), + ) + op.create_table( + "permissions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", postgresql.UUID(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("role", sa.Integer(), nullable=True), + sa.Column("user_uuid", postgresql.UUID(), nullable=False), + sa.Column("board_uuid", postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(["board_uuid"], ["boards.uuid"]), + sa.ForeignKeyConstraint(["user_uuid"], ["users.uuid"]), + sa.PrimaryKeyConstraint("id", "uuid"), + sa.UniqueConstraint( + "board_uuid", "user_uuid", name="unique_board_owner_permission" + ), + sa.UniqueConstraint("uuid"), + ) + op.create_table( + "tasks", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", postgresql.UUID(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("expire_at", sa.DateTime(), nullable=True), + sa.Column("label", sa.String(length=80), nullable=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("description", sa.String(length=120), nullable=False), + sa.Column("state", sa.Integer(), nullable=True), + sa.Column("position", sa.Integer(), nullable=True), + sa.Column("column_uuid", postgresql.UUID(), nullable=False), + sa.Column("creator_uuid", postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(["column_uuid"], ["columns.uuid"]), + sa.ForeignKeyConstraint(["creator_uuid"], ["users.uuid"]), + sa.PrimaryKeyConstraint("id", "uuid"), + sa.UniqueConstraint("uuid"), + ) + op.create_table( + "users_tasks", + sa.Column("task_uuid", postgresql.UUID(), nullable=False), + sa.Column("user_uuid", postgresql.UUID(), nullable=False), + sa.ForeignKeyConstraint(["task_uuid"], ["tasks.uuid"]), + sa.ForeignKeyConstraint(["user_uuid"], ["users.uuid"]), + sa.PrimaryKeyConstraint("task_uuid", "user_uuid"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("users_tasks") + op.drop_table("tasks") + op.drop_table("permissions") + op.drop_table("columns") + op.drop_index(op.f("ix_boards_description"), table_name="boards") + op.drop_table("boards") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c1fa878 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -p no:warnings \ No newline at end of file diff --git a/requirements/__init__.py b/requirements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..ae2cf72 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,19 @@ +Flask==1.1.1 +Flask-Migrate +Flask_Caching +Flask_Bcrypt +Flask-JWT-Extended +Flask-Cors +Flask_SQLAlchemy==2.2 +SQLAlchemy>=1.3.0 +psycopg2-binary +marshmallow +marshmallow-enum==1.5.1 +MarkupSafe==1.1.1 +gunicorn==20.0.4 +uuid +environs +unicode_slugify +Click==7.0 +Flask-Babel>=0.12.2 +redis==2.10.6 \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..a0bbaa8 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,19 @@ +-r ./base.txt + +# API docs +flask-swagger-ui==3.20.9 +apispec>=1.0.0 +apispec-webframeworks +flask_apispec +pyyaml + +# Tests +pytest>=5.4.0 +pytest-mock>=3.1.0 +pytest-cov>=2.8.1 +factory-boy +WebTest +Faker + +# Formatters +black \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..ee51fcd --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1 @@ +-r ./base.txt \ No newline at end of file diff --git a/task_office/__init__.py b/task_office/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/task_office/__init__.py @@ -0,0 +1 @@ + diff --git a/task_office/api/__init__.py b/task_office/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/api/v1/__init__.py b/task_office/api/v1/__init__.py new file mode 100644 index 0000000..14cd5bd --- /dev/null +++ b/task_office/api/v1/__init__.py @@ -0,0 +1 @@ +from . import views diff --git a/task_office/api/v1/views.py b/task_office/api/v1/views.py new file mode 100644 index 0000000..eee9842 --- /dev/null +++ b/task_office/api/v1/views.py @@ -0,0 +1,5 @@ +from flask.blueprints import Blueprint + +from task_office.settings import app_config + +bp = Blueprint("api_v1", __name__, url_prefix=app_config.API_PREFIX) diff --git a/task_office/app.py b/task_office/app.py new file mode 100644 index 0000000..2b50070 --- /dev/null +++ b/task_office/app.py @@ -0,0 +1,105 @@ +"""The app module, containing the app factory function.""" +from flask import Flask + +from task_office import commands, swagger, core +from task_office.api import v1 as api_v1 +from task_office.auth.jwt_error_handlers import jwt_errors_map +from task_office.exceptions import InvalidUsage +from task_office.extensions.babel import babel +from task_office.extensions.bcrypt import bcrypt +from task_office.extensions.cache import cache +from task_office.extensions.cors import init_cors +from task_office.extensions.db import db +from task_office.extensions.jwt import init_jwt +from task_office.extensions.migrate import init_migrate +from task_office.settings import app_config +from task_office.swagger import SWAGGER_URL + +from task_office.tasks import views +from task_office.permissions import views +from task_office.boards import views +from task_office.columns import views +from task_office.auth import views + + +def create_app(config_object): + """An application factory, as explained here: + http://flask.pocoo.org/docs/patterns/appfactories/. + + :param config_object: The configuration object to use. + """ + app = Flask( + __name__.split(".")[0], + static_folder=app_config.STATIC_DIR, + static_url_path=app_config.STATIC_URL, + ) + app.url_map.strict_slashes = False + app.config.from_object(config_object) + register_extensions(app) + register_blueprints(app) + register_error_handlers(app) + register_shell_context(app) + register_commands(app) + return app + + +def register_extensions(app): + """Register Flask extensions.""" + db.init_app(app) + babel.init_app(app) + bcrypt.init_app(app) + cache.init_app(app, app_config.CACHE) + + init_jwt(app) + init_migrate(app, db) + init_cors(app) + + +def register_blueprints(app): + """Register Flask blueprints.""" + app.register_blueprint(api_v1.views.bp) + + # root endpoint + app.register_blueprint(core.views.bp) + + if app_config.USE_DOCS: + app.register_blueprint(swagger.views.bp_swagger, url_prefix=SWAGGER_URL) + app.register_blueprint(swagger.views.bp) + + +def register_error_handlers(app): + def error_handler(error): + response = error.to_json() + response.status_code = error.status_code + return response + + app.errorhandler(InvalidUsage)(error_handler) + + # register errors(wrapped) for jwt extended custom + def jwt_error_handler(error): + wrapped_error_handler = jwt_errors_map[str(error.__class__.__name__)]["handler"] + unwrapped_error = wrapped_error_handler(error) + return error_handler(unwrapped_error) + + [ + app.errorhandler(payload["error"])(jwt_error_handler) + for error_name, payload in jwt_errors_map.items() + ] + + +def register_shell_context(app): + """Register shell context objects.""" + + def shell_context(): + """Shell context objects.""" + return { + "db": db, + # 'Article': articles.models.Article, + } + + app.shell_context_processor(shell_context) + + +def register_commands(app): + """Register Click commands.""" + app.cli.add_command(commands.test) diff --git a/task_office/auth/__init__.py b/task_office/auth/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/auth/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/auth/constants.py b/task_office/auth/constants.py new file mode 100644 index 0000000..a64a485 --- /dev/null +++ b/task_office/auth/constants.py @@ -0,0 +1,2 @@ +APP_PREFIX = "/auth" +APP_PREFIX_RETRIEVE = APP_PREFIX diff --git a/task_office/auth/jwt_error_handlers.py b/task_office/auth/jwt_error_handlers.py new file mode 100644 index 0000000..fc4c15e --- /dev/null +++ b/task_office/auth/jwt_error_handlers.py @@ -0,0 +1,109 @@ +from flask_babel import lazy_gettext as _ +from flask_jwt_extended import get_jwt_identity +from flask_jwt_extended.exceptions import ( + NoAuthorizationError, + CSRFError, + InvalidHeaderError, + JWTDecodeError, + WrongTokenError, + RevokedTokenError, + FreshTokenRequired, + UserLoadError, + UserClaimsVerificationError, +) +from jwt import ExpiredSignatureError, InvalidTokenError + +from task_office.exceptions import InvalidUsage + + +def handle_auth_error(e): + return InvalidUsage(messages=[str(e)], status_code=401) + + +def handle_expired_error(e): + return InvalidUsage(messages=[str(e)], status_code=401) + + +def handle_invalid_header_error(e): + return InvalidUsage(messages=[str(e)], status_code=422) + + +def handle_invalid_token_error(e): + return InvalidUsage(messages=[str(e)], status_code=422) + + +def handle_jwt_decode_error(e): + return InvalidUsage(messages=[str(e)], status_code=422) + + +def handle_wrong_token_error(e): + return InvalidUsage(messages=[str(e)], status_code=401) + + +def handle_revoked_token_error(e): + return InvalidUsage(messages=[_("Token has been revoked")], status_code=401) + + +def handle_fresh_token_required(e): + return InvalidUsage(messages=[_("Fresh token required")], status_code=401) + + +def handler_user_load_error(e): + # The identity is already saved before this exception was raised, + # otherwise a different exception would be raised, which is why we + # can safely call get_jwt_identity() here + identity = get_jwt_identity() + return InvalidUsage( + messages=[_("Error loading the user {}").format(identity)], status_code=401 + ) + + +def handle_failed_user_claims_verification(e): + return InvalidUsage( + messages=[_("User claims verification failed")], status_code=400 + ) + + +jwt_errors_map = { + NoAuthorizationError.__name__: { + "handler": handle_auth_error, + "error": NoAuthorizationError, + }, + CSRFError.__name__: {"handler": handle_auth_error, "error": CSRFError}, + ExpiredSignatureError.__name__: { + "handler": handle_expired_error, + "error": ExpiredSignatureError, + }, + InvalidHeaderError.__name__: { + "handler": handle_invalid_header_error, + "error": InvalidHeaderError, + }, + InvalidTokenError.__name__: { + "handler": handle_invalid_token_error, + "error": InvalidTokenError, + }, + JWTDecodeError.__name__: { + "handler": handle_jwt_decode_error, + "error": JWTDecodeError, + }, + WrongTokenError.__name__: { + "handler": handle_wrong_token_error, + "error": WrongTokenError, + }, + RevokedTokenError.__name__: { + "handler": handle_revoked_token_error, + "error": RevokedTokenError, + }, + FreshTokenRequired.__name__: { + "handler": handle_fresh_token_required, + "error": FreshTokenRequired, + }, + UserLoadError.__name__: { + "handler": handler_user_load_error, + "error": UserLoadError, + }, + UserClaimsVerificationError.__name__: { + "handler": handle_failed_user_claims_verification, + "error": UserClaimsVerificationError, + }, +} diff --git a/task_office/auth/schemas.py b/task_office/auth/schemas.py new file mode 100644 index 0000000..9cf20b3 --- /dev/null +++ b/task_office/auth/schemas.py @@ -0,0 +1,115 @@ +from flask_babel import lazy_gettext as _ +from marshmallow import fields, validates_schema +from marshmallow.validate import Length + +from task_office.core.models.db_models import User +from task_office.core.schemas.base_schemas import BaseSchema, XSchema +from task_office.core.validators import Unique +from task_office.settings import app_config + + +class UserSchema(BaseSchema): + username = fields.Str(dump_only=True) + email = fields.Email(dump_only=True) + bio = fields.Str(dump_only=True) + + class Meta: + strict = True + + +user_schema = UserSchema() +user_schemas = UserSchema(many=True) + + +class UserSignUpSchema(XSchema): + error_messages = {"passwords_dismatch": _("Passwords do not match")} + + password_confirm = fields.Str( + required=True, load_only=True, validate=[Length(min=6, max=32)] + ) + password = fields.Str( + required=True, load_only=True, validate=[Length(min=6, max=32)] + ) + email = fields.Email( + required=True, validate=[Length(max=255), Unique(User, "email")] + ) + username = fields.Str( + required=True, validate=[Length(min=3, max=80), Unique(User, "username")] + ) + + @validates_schema + def validate_schema(self, data, **kwargs): + password_confirm = data["password_confirm"] + password = data["password"] + if password != password_confirm: + self.throw_error(value="", key_error="passwords_dismatch", code=422) + + class Meta: + strict = True + + +user_signup_schema = UserSignUpSchema() + + +class UserSignInSchema(XSchema): + error_messages = {"usr_not_found": _("User not found")} + + password = fields.Str( + required=True, load_only=True, validate=[Length(min=6, max=32)] + ) + email = fields.Email(required=True, load_only=True, validate=[Length(max=255)]) + + @validates_schema + def validate_schema(self, data, **kwargs): + email = data["email"] + password = data["password"] + user = User.query.filter_by(email=email).first() + if user is not None and user.check_password(password): + data["user"] = user + else: + self.throw_error(value="", key_error="usr_not_found", code=422) + + class Meta: + strict = True + + +user_signin_schema = UserSignInSchema() + + +class TokenSchema(XSchema): + token = fields.Str() + lifetime = fields.Integer() + + +class SignedTokensSchema(XSchema): + access = fields.Nested(TokenSchema) + refresh = fields.Nested(TokenSchema) + header_type = fields.Str() + time_zone_info = fields.Str() + + +class RefreshedAccessTokenSchema(XSchema): + access = fields.Nested(TokenSchema) + time_zone_info = fields.Str() + + +class SignedSchema(XSchema): + tokens = fields.Nested(SignedTokensSchema) + user = fields.Nested(UserSchema) + + +signed_schema = SignedSchema() +signed_tokens_schema = SignedTokensSchema() +refreshed_access_tokens_schema = RefreshedAccessTokenSchema() +token_schema = TokenSchema() + + +app_config.API_SPEC.components.schema("UserSchema", schema=UserSchema) +app_config.API_SPEC.components.schema("UserSignInSchema", schema=UserSignInSchema) +app_config.API_SPEC.components.schema("UserSignUpSchema", schema=UserSignUpSchema) +app_config.API_SPEC.components.schema("TokenSchema", schema=TokenSchema) +app_config.API_SPEC.components.schema("SignedTokensSchema", schema=SignedTokensSchema) +app_config.API_SPEC.components.schema( + "RefreshedAccessTokenSchema", schema=RefreshedAccessTokenSchema +) +app_config.API_SPEC.components.schema("SignedSchema", schema=SignedSchema) diff --git a/task_office/auth/utils.py b/task_office/auth/utils.py new file mode 100644 index 0000000..1aeff57 --- /dev/null +++ b/task_office/auth/utils.py @@ -0,0 +1,65 @@ +import uuid + +from flask_babel import lazy_gettext as _ +from flask_jwt_extended import get_current_user + +from task_office.core.models.db_models import Permission +from task_office.exceptions import InvalidUsage +from task_office.extensions.cache import cache +from task_office.settings import app_config + + +def _get_cached_permissions(): + user = get_current_user() + perms = dict() + if user: + pk = uuid.UUID(user.uuid).hex + key = f"perms_{pk}" + perms = cache.get(key) + if not perms: + perms = {uuid.UUID(item.board_uuid).hex: item.role for item in user.perms} + if perms: + cache.set( + key, perms, timeout=app_config.JWT_ACCESS_TOKEN_EXPIRES.seconds + ) + return perms + + +def reset_permissions(user_uuid_hexed): + """ + Clear permissions from cache + :param user_uuid_hexed: + :return: + """ + key = f"perms_{user_uuid_hexed}" + return cache.delete(key) + + +def reset_permissions_for_board_staff(board_uuid): + """ + Clear permissions for board staff(Role.STAFF) + :param board_uuid: + :return: + """ + perms = Permission.query.filter_by( + board_uuid=board_uuid, role=Permission.Role.STAFF.value + ).all() + for item in perms: + user_uuid = uuid.UUID(str(item.user_uuid)).hex + reset_permissions(user_uuid) + + +def permission(required_role: int): + def decorator(func): + def wrapper(*args, **kwargs): + board_uuid = kwargs.get("board_uuid", None) + perms = _get_cached_permissions() + current_role = perms.get(board_uuid, -1) + if current_role > required_role: + raise InvalidUsage(messages=[_("Not allowed")], status_code=403) + return func(*args, **kwargs) + + wrapper.__name__ = func.__name__ + return wrapper + + return decorator diff --git a/task_office/auth/views.py b/task_office/auth/views.py new file mode 100644 index 0000000..ec8f24b --- /dev/null +++ b/task_office/auth/views.py @@ -0,0 +1,90 @@ +"""User views.""" +from datetime import datetime + +from flask_apispec import use_kwargs, marshal_with +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + jwt_refresh_token_required, + get_jwt_identity, +) + +from .constants import APP_PREFIX +from .schemas import ( + user_schema, + user_signup_schema, + user_signin_schema, + signed_schema, + refreshed_access_tokens_schema, +) +from ..api.v1.views import bp +from ..core.models.db_models import User +from ..settings import app_config + + +@bp.route(APP_PREFIX + "/sign-up", methods=("post",)) +@use_kwargs(user_signup_schema) +@marshal_with(user_schema) +def sign_up(**kwargs): + """ + :param kwargs: + :return: + """ + data = kwargs + data.pop("password_confirm") + user = User(**data) + user.save() + return user + + +@bp.route(APP_PREFIX + "/sign-in", methods=("post",)) +@use_kwargs(user_signin_schema) +@marshal_with(signed_schema) +def sign_in(**kwargs): + """ + :param kwargs: + :return: + """ + data = kwargs + refresh_lf = datetime.timestamp( + datetime.utcnow() + app_config.JWT_REFRESH_TOKEN_EXPIRES + ) + access_lf = datetime.timestamp( + datetime.utcnow() + app_config.JWT_ACCESS_TOKEN_EXPIRES + ) + return { + "user": data["user"], + "tokens": { + "access": { + "lifetime": access_lf, + "token": create_access_token(identity=data["user"], fresh=True), + }, + "refresh": { + "lifetime": refresh_lf, + "token": create_refresh_token(identity=data["user"]), + }, + "header_type": app_config.JWT_AUTH_HEADER_PREFIX, + "time_zone_info": app_config.TIME_ZONE, + }, + } + + +@bp.route(APP_PREFIX + "/refresh", methods=("post",)) +@jwt_refresh_token_required +@marshal_with(refreshed_access_tokens_schema) +def refresh(**kwargs): + """ + :param kwargs: + :return: + """ + current_user = User.get_by_id(get_jwt_identity()) + access_lf = datetime.timestamp( + datetime.utcnow() + app_config.JWT_ACCESS_TOKEN_EXPIRES + ) + return { + "access": { + "lifetime": access_lf, + "token": create_access_token(identity=current_user, fresh=True), + }, + "time_zone_info": app_config.TIME_ZONE, + } diff --git a/task_office/boards/__init__.py b/task_office/boards/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/boards/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/boards/constants.py b/task_office/boards/constants.py new file mode 100644 index 0000000..9489ccd --- /dev/null +++ b/task_office/boards/constants.py @@ -0,0 +1,2 @@ +APP_PREFIX = "/boards" +APP_PREFIX_RETRIEVE = APP_PREFIX + "/" diff --git a/task_office/boards/schemas/__init__.py b/task_office/boards/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/boards/schemas/basic_schemas.py b/task_office/boards/schemas/basic_schemas.py new file mode 100644 index 0000000..3d81bba --- /dev/null +++ b/task_office/boards/schemas/basic_schemas.py @@ -0,0 +1,77 @@ +import uuid + +from marshmallow import fields, validates_schema +from marshmallow.validate import Length +from marshmallow_enum import EnumField + +from task_office.boards.schemas.search_schemas import SearchUserSchema +from task_office.core.enums import XEnum +from task_office.core.schemas.base_schemas import BaseSchema, ListSchema, XSchema +from task_office.settings import app_config + + +class BoardActionsSchema(BaseSchema): + name = fields.Str(required=True, allow_none=False, validate=[Length(min=1, max=80)]) + description = fields.Str( + allow_none=False, required=False, default="", validate=[Length(min=0, max=255)] + ) + is_active = fields.Boolean(default=True) + + class Meta: + strict = True + + +class BoardDumpSchema(BaseSchema): + name = fields.Str(dump_only=True) + description = fields.Str(dump_only=True) + is_active = fields.Boolean(dump_only=True) + + class Meta: + strict = True + + @validates_schema + def validate_schema(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + + +board_action_schema = BoardActionsSchema() +board_dump_schema = BoardDumpSchema() +board_list_dump_schema = BoardDumpSchema(many=True) +app_config.API_SPEC.components.schema("BoardActionsSchema", schema=BoardActionsSchema) +app_config.API_SPEC.components.schema("BoardDumpSchema", schema=BoardDumpSchema) + + +class BoardListQuerySchema(ListSchema): + class OrderingMap(XEnum): + pass + + searching = fields.Nested(XSchema, required=False) + ordering = EnumField(OrderingMap, required=False, by_value=True) + + class Meta: + strict = True + + +board_list_query_schema = BoardListQuerySchema() +app_config.API_SPEC.components.schema( + "BoardListQuerySchema", schema=BoardListQuerySchema +) + + +class UserListByBoardQuerySchema(ListSchema): + SEARCHING_SCHEMA = SearchUserSchema + + class OrderingMap(XEnum): + pass + + searching = fields.Nested(SEARCHING_SCHEMA, required=False) + ordering = EnumField(OrderingMap, required=False, by_value=True) + + class Meta: + strict = True + + +user_list_by_board_query_schema = UserListByBoardQuerySchema() +app_config.API_SPEC.components.schema( + "UserListByBoardQuerySchema", schema=UserListByBoardQuerySchema +) diff --git a/task_office/boards/schemas/search_schemas.py b/task_office/boards/schemas/search_schemas.py new file mode 100644 index 0000000..17b608b --- /dev/null +++ b/task_office/boards/schemas/search_schemas.py @@ -0,0 +1,19 @@ +from marshmallow import fields + +from task_office.core.models.db_models import User +from task_office.core.schemas.base_schemas import SearchSchema +from task_office.settings import app_config + + +class SearchUserSchema(SearchSchema): + FIELDS_MAP = {"username": User.username, "email": User.email} + + username = fields.Str(required=False, allow_none=False) + email = fields.Email(required=False, allow_none=False) + + class Meta: + strict = True + + +search_user_schema = SearchUserSchema() +app_config.API_SPEC.components.schema("SearchUserSchema", schema=SearchUserSchema) diff --git a/task_office/boards/views.py b/task_office/boards/views.py new file mode 100644 index 0000000..0b31333 --- /dev/null +++ b/task_office/boards/views.py @@ -0,0 +1,126 @@ +"""Boards views.""" +from datetime import datetime + +from flask_apispec import use_kwargs, marshal_with +from flask_jwt_extended import jwt_required, get_current_user +from sqlalchemy.orm import aliased + +from .constants import APP_PREFIX, APP_PREFIX_RETRIEVE +from .schemas.basic_schemas import ( + board_action_schema, + board_list_query_schema, + board_list_dump_schema, + board_dump_schema, + user_list_by_board_query_schema, +) +from ..api.v1.views import bp +from ..auth.utils import permission, reset_permissions_for_board_staff +from ..core.helpers.listed_response import listed_response +from ..core.models.db_models import Board, Permission, User +from ..core.schemas.nested_schemas import nested_user_list_dump_schema +from ..core.utils import ( + empty_query_required, + validate_request_url_uuid, + non_empty_query_required, +) + + +@bp.route(APP_PREFIX, methods=("post",)) +@jwt_required +@use_kwargs(board_action_schema) +@marshal_with(board_dump_schema) +def create_board(**kwargs): + data = kwargs + + # validate current user + user = get_current_user() + user_uuid = user.hexed_uuid() if user else None + data["owner_uuid"] = user_uuid + + # Check name, owner_uuid are unique for board + empty_query_required(Board, name=data["name"], owner_uuid=data["owner_uuid"]) + + board = Board(**data) + board.save() + + # Create permission for creator + Permission( + user_uuid=data["owner_uuid"], + board_uuid=board.hexed_uuid(), + role=Permission.Role.OWNER.value, + ).save() + + return board + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("put",)) +@jwt_required +@use_kwargs(board_action_schema) +@marshal_with(board_dump_schema) +def update_board(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + board = non_empty_query_required(Board, uuid=board_uuid)[1] + + if data.get("name", board.name) != board.name: + empty_query_required(Board, name=data["name"], owner_uuid=str(board.owner_uuid)) + + if data.get("is_active", None) is not None: + reset_permissions_for_board_staff(board_uuid) + + board.update(updated_at=datetime.utcnow(), **data) + board.save() + + return board + + +@bp.route(APP_PREFIX, methods=("get",)) +@jwt_required +@use_kwargs(board_list_query_schema) +def get_list_boards(**kwargs): + data = kwargs + user = get_current_user() + boards = Board.query.join(Permission).filter(Permission.user_uuid == user.uuid) + # Serialize to paginated response + data = listed_response.serialize( + query=boards, query_params=data, schema=board_list_dump_schema + ) + return data + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("get",)) +@jwt_required +@marshal_with(board_dump_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_board(board_uuid): + + board = validate_request_url_uuid(Board, "uuid", board_uuid, True)[1] + + return board + + +@bp.route(APP_PREFIX_RETRIEVE + "/users", methods=("get",)) +@jwt_required +@use_kwargs(user_list_by_board_query_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_board_users(board_uuid, **kwargs): + data = kwargs + + # board_uuid in request url + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + perms_q = aliased( + Permission.query.filter(Permission.board_uuid == board_uuid).subquery(), + name="perms", + ) + users_q = User.query.join(perms_q).filter() + data = listed_response.serialize( + query=users_q, query_params=data, schema=nested_user_list_dump_schema + ) + return data diff --git a/task_office/columns/__init__.py b/task_office/columns/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/columns/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/columns/constants.py b/task_office/columns/constants.py new file mode 100644 index 0000000..0e437a0 --- /dev/null +++ b/task_office/columns/constants.py @@ -0,0 +1,4 @@ +from task_office.boards.constants import APP_PREFIX_RETRIEVE as BOARD_PREFIX_RETRIEVE + +APP_PREFIX = BOARD_PREFIX_RETRIEVE + "/columns" +APP_PREFIX_RETRIEVE = APP_PREFIX + "/" diff --git a/task_office/columns/schemas/__init__.py b/task_office/columns/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/columns/schemas/basic_schemas.py b/task_office/columns/schemas/basic_schemas.py new file mode 100644 index 0000000..9026891 --- /dev/null +++ b/task_office/columns/schemas/basic_schemas.py @@ -0,0 +1,80 @@ +import uuid + +from marshmallow import fields, post_dump +from marshmallow.validate import Length, Range +from marshmallow_enum import EnumField + +from task_office.core.enums import XEnum, OrderingDirection +from task_office.core.models.db_models import BoardColumn +from task_office.core.schemas.base_schemas import BaseSchema, ListSchema, XSchema +from task_office.settings import app_config + + +class ColumnPostSchema(BaseSchema): + name = fields.Str( + required=True, allow_none=False, validate=[Length(min=1, max=120)] + ) + position = fields.Integer( + required=True, default=1, allow_none=False, validate=[Range(min=1)] + ) + + class Meta: + strict = True + + +class ColumnPutSchema(BaseSchema): + name = fields.Str( + required=False, allow_none=False, validate=[Length(min=1, max=120)] + ) + position = fields.Integer(required=False, allow_none=False, validate=[Range(min=1)]) + + class Meta: + strict = True + + +class ColumnDumpSchema(BaseSchema): + name = fields.Str(dump_only=True) + position = fields.Integer(dump_only=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + class Meta: + strict = True + + +column_post_schema = ColumnPostSchema() +column_put_schema = ColumnPutSchema() +column_dump_schema = ColumnDumpSchema() +column_listed_dump_schema = ColumnDumpSchema(many=True) +app_config.API_SPEC.components.schema("ColumnPostSchema", schema=ColumnPostSchema) +app_config.API_SPEC.components.schema("ColumnPutSchema", schema=ColumnPutSchema) +app_config.API_SPEC.components.schema("ColumnDumpSchema", schema=ColumnDumpSchema) + + +class ColumnListQuerySchema(ListSchema): + class OrderingMap(XEnum): + CREATED_AT_ASC = ( + "-created_at", + BoardColumn.created_at.asc(), + OrderingDirection.ASC, + ) + CREATED_AET_DESC = ( + "created_at", + BoardColumn.created_at.desc(), + OrderingDirection.DESC, + ) + + searching = fields.Nested(XSchema, required=False) + ordering = EnumField(OrderingMap, required=False, by_value=True) + + class Meta: + strict = True + + +column_list_query_schema = ColumnListQuerySchema() +app_config.API_SPEC.components.schema( + "ColumnListQuerySchema", schema=ColumnListQuerySchema +) diff --git a/task_office/columns/utils.py b/task_office/columns/utils.py new file mode 100644 index 0000000..bbab9a5 --- /dev/null +++ b/task_office/columns/utils.py @@ -0,0 +1,39 @@ +"""Columns utils.""" +from task_office.core.models.db_models import BoardColumn +from task_office.database import Model +from task_office.extensions.db import db + + +def reset_columns_ordering( + instance: Model, board_uuid: str, new_value: int, old_value: int = None +) -> bool: + if new_value != old_value: + if old_value is None: + # if column already created + qs = BoardColumn.query.filter( + BoardColumn.id != instance.id, + BoardColumn.board_uuid == board_uuid, + BoardColumn.position >= new_value, + ) + qs.update(dict(position=BoardColumn.position + 1)) + elif new_value > old_value: + qs = BoardColumn.query.filter( + BoardColumn.id != instance.id, + BoardColumn.board_uuid == board_uuid, + BoardColumn.position > old_value, + BoardColumn.position <= new_value, + ) + qs.update(dict(position=BoardColumn.position - 1)) + else: + BoardColumn.query.filter( + BoardColumn.id != instance.id, + BoardColumn.board_uuid == board_uuid, + BoardColumn.position < old_value, + BoardColumn.position >= new_value, + ).update(dict(position=BoardColumn.position + 1)) + + db.session.commit() + + return True + + return False diff --git a/task_office/columns/views.py b/task_office/columns/views.py new file mode 100644 index 0000000..cc027cd --- /dev/null +++ b/task_office/columns/views.py @@ -0,0 +1,160 @@ +"""Columns views.""" +from datetime import datetime + +from flask_apispec import use_kwargs, marshal_with +from flask_babel import lazy_gettext as _ +from flask_jwt_extended import jwt_required +from sqlalchemy import func + +from .constants import APP_PREFIX, APP_PREFIX_RETRIEVE +from .schemas.basic_schemas import ( + column_post_schema, + column_list_query_schema, + column_listed_dump_schema, + column_dump_schema, + column_put_schema, +) +from .utils import reset_columns_ordering +from ..api.v1.views import bp +from ..core.helpers.listed_response import listed_response +from ..core.models.db_models import BoardColumn, Board +from ..core.utils import validate_request_url_uuid, non_empty_query_required +from ..exceptions import InvalidUsage +from ..extensions import db + + +@bp.route(APP_PREFIX + "/meta", methods=("get",)) +@jwt_required +def get_columns_meta_data(board_uuid): + """ + Additional data for Columns + """ + validate_request_url_uuid(Board, "uuid", board_uuid, True) + data = dict() + return data + + +@bp.route(APP_PREFIX, methods=("post",)) +@jwt_required +@use_kwargs(column_post_schema) +@marshal_with(column_dump_schema) +def create_column(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + # validate max position + data["position"] = data.get("position", 1) + position = data["position"] + if position > 1: + max_position = db.session.query(func.max(BoardColumn.position)).scalar() + max_position = 1 if max_position is None else max_position + 1 + if position > max_position: + raise InvalidUsage( + messages=[_("Must be between {} and {}".format(1, max_position))], + status_code=422, + key="position", + ) + + # validate unique name for board + q_by_name = BoardColumn.query.filter_by( + board_uuid=board_uuid, name=data["name"] + ).first() + if q_by_name: + raise InvalidUsage( + messages=[_("Already exists with value {}".format(data["name"]))], + status_code=422, + key="name", + ) + + column = BoardColumn(board_uuid=board_uuid, **data) + column.save() + + reset_columns_ordering(column, board_uuid, position) + + return column + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("put",)) +@jwt_required +@use_kwargs(column_put_schema) +@marshal_with(column_dump_schema) +def update_column(board_uuid, column_uuid, **kwargs): + """ + :param board_uuid: + :param column_uuid: + :param kwargs: + :return: + """ + data = kwargs + + validate_request_url_uuid(Board, "uuid", board_uuid, True) + validate_request_url_uuid(BoardColumn, "uuid", column_uuid, True) + + # validate max position value + position = data.get("position", None) + if position is not None and position > 1: + max_position = db.session.query(func.max(BoardColumn.position)).scalar() + max_position = 1 if max_position is None else max_position + 1 + + if position > max_position: + raise InvalidUsage( + messages=[_("Must be between {} and {}.".format(1, max_position))], + status_code=422, + key="position", + ) + + # validate unique name for board + q_by_name = BoardColumn.query.filter( + BoardColumn.uuid != column_uuid, + BoardColumn.board_uuid == board_uuid, + BoardColumn.name == data["name"], + ).first() + if q_by_name: + raise InvalidUsage( + messages=[_("Already exists with value {}".format(data["name"]))], + status_code=422, + key="name", + ) + + column = non_empty_query_required( + BoardColumn, uuid=str(column_uuid), board_uuid=str(board_uuid) + )[1] + + if data: + old_position = column.position + column.update(updated_at=datetime.utcnow(), **data) + column.save() + if position: + reset_columns_ordering(column, board_uuid, position, old_position) + + return column + + +@bp.route(APP_PREFIX, methods=("get",)) +@jwt_required +@use_kwargs(column_list_query_schema) +def get_list_columns(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + + # Check board_uuid in request_url + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + columns = BoardColumn.query.order_by( + BoardColumn.position, BoardColumn.id.asc() + ).filter_by(board_uuid=board_uuid) + + # Serialize to paginated response + data = listed_response.serialize( + query=columns, query_params=data, schema=column_listed_dump_schema + ) + return data diff --git a/task_office/commands.py b/task_office/commands.py new file mode 100644 index 0000000..306dc7c --- /dev/null +++ b/task_office/commands.py @@ -0,0 +1,14 @@ +"""Click commands.""" +import os + +import click + +HERE = os.path.abspath(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.join(HERE, os.pardir) +TEST_PATH = os.path.join(PROJECT_ROOT, "tests") + + +@click.command() +def test(): + """Run the tests.""" + pass diff --git a/task_office/compat.py b/task_office/compat.py new file mode 100644 index 0000000..448c90a --- /dev/null +++ b/task_office/compat.py @@ -0,0 +1,18 @@ +"""Python 2/3 compatibility module.""" +import sys + +PY2 = int(sys.version[0]) == 2 + +if PY2: + pass + # text_type = unicode # noqa + # binary_type = str + # string_types = (str, unicode) # noqa + # unicode = unicode # noqa + # basestring = basestring # noqa +else: + text_type = str + binary_type = bytes + string_types = (str,) + unicode = str + basestring = (str, bytes) diff --git a/task_office/core/__init__.py b/task_office/core/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/core/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/core/constants.py b/task_office/core/constants.py new file mode 100644 index 0000000..4ad2102 --- /dev/null +++ b/task_office/core/constants.py @@ -0,0 +1,8 @@ +LOOKUP_MAP = { + "gt": lambda q, k, v: q.filter(k > v), + "gte": lambda q, k, v: q.filter(k >= v), + "lt": lambda q, k, v: q.filter(k < v), + "lte": lambda q, k, v: q.filter(k <= v), + "e": lambda q, k, v: q.filter(k == v), + "ne": lambda q, k, v: q.filter(k != v), +} diff --git a/task_office/core/enums.py b/task_office/core/enums.py new file mode 100644 index 0000000..1ba70f3 --- /dev/null +++ b/task_office/core/enums.py @@ -0,0 +1,48 @@ +from enum import Enum +from flask_babel import lazy_gettext as _ + + +class XEnum(Enum): + """ + Base Enum for project + """ + + # ADMIN = 0, _('Admin'), _('Approximate quantity: format +/-') + # EDITOR = 1, _('Editor'), _('Strict, exact quantity: format numeric') + + @classmethod + def choices(cls): + return [(tag.name, tag.value) for tag in cls] + + @classmethod + def dict_choices(cls): + return [ + {"name": tag.name, "value": tag.value, "description": tag.description} + for tag in cls + ] + + @classmethod + def get_names(cls): + return [item.name for item in cls] + + @classmethod + def get_values(cls): + return [item.value for item in cls] + + def __new__(cls, value, name, description=""): + member = object.__new__(cls) + member._value_ = value + member.fullname = name + member.description = description + return member + + def __int__(self): + return self.value + + def __str__(self): + return self.value + + +class OrderingDirection(XEnum): + ASC = "asc", _("Ascend") + DESC = "desc", _("Descend") diff --git a/task_office/core/helpers/__init__.py b/task_office/core/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/core/helpers/listed_response.py b/task_office/core/helpers/listed_response.py new file mode 100644 index 0000000..c3e3fd8 --- /dev/null +++ b/task_office/core/helpers/listed_response.py @@ -0,0 +1,63 @@ +from flask import request +from flask_babel import lazy_gettext as _ + +from task_office.core.utils import lookup_filter +from task_office.exceptions import InvalidUsage +from task_office.settings import app_config + + +class ListedResponseHelper: + error_messages = {"max_offset_exceeded": _("Max value {} exceeded")} + RESPONSE_TEMPLATE = {"count": None, "results": [], "next": None, "prev": None} + RESPONSE_PAGINATED_QS_TEMPLATE = "?limit={}&offset={}" + + @staticmethod + def _get_query_ordered(query, order_param): + if order_param: + query = query.order_by(order_param.fullname) + return query + + @staticmethod + def _get_query_filtered(query, filter_params): + if filter_params: + for k, v in filter_params.items(): + query = lookup_filter(query, v["key"], v["value"], v["lookup"]) + return query + + @staticmethod + def _get_query_paginated(query, limit, offset): + if offset > 0: + query = query.offset(offset) + query = query.limit(limit) + return query + + def serialize(self, query, query_params, schema): + query = self._get_query_filtered(query, query_params.get("searching", {})) + query = self._get_query_ordered(query, query_params.get("ordering", "")) + count = query.count() + + limit = query_params.get("limit", app_config.DEFAULT_LIMIT_VALUE) + offset = query_params.get("offset", app_config.DEFAULT_OFFSET_VALUE) + query = self._get_query_paginated(query, limit, offset) + + data = dict(self.RESPONSE_TEMPLATE) + if offset >= count and offset > 0: + raise InvalidUsage( + messages=[self.error_messages["max_offset_exceeded"].format(count - 1)], + status_code=422, + key="offset", + ) + if (offset + limit) <= count: + data[ + "next" + ] = f"{request.base_url}{self.RESPONSE_PAGINATED_QS_TEMPLATE.format(limit, offset + limit)}" + if (offset - limit) > 0: + data[ + "prev" + ] = f"{request.base_url}{self.RESPONSE_PAGINATED_QS_TEMPLATE.format(limit, offset - limit)}" + data["count"] = count + data["results"] = schema.dump(query) + return data + + +listed_response = ListedResponseHelper() diff --git a/task_office/core/models/__init__.py b/task_office/core/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/task_office/core/models/__init__.py @@ -0,0 +1 @@ + diff --git a/task_office/core/models/db_models.py b/task_office/core/models/db_models.py new file mode 100644 index 0000000..4519814 --- /dev/null +++ b/task_office/core/models/db_models.py @@ -0,0 +1,163 @@ +"""Boards models.""" +from flask_babel import lazy_gettext as _ +from sqlalchemy.dialects.postgresql import UUID + +from task_office.core.enums import XEnum +from task_office.core.models.mixins import DTMixin, PKMixin +from task_office.database import Column, Model, db, reference_col, relationship +from task_office.extensions.bcrypt import bcrypt + + +class User(PKMixin, DTMixin, Model): + + __tablename__ = "users" + + username = Column(db.String(80), unique=True, nullable=False) + email = Column(db.String(255), unique=True, nullable=False, index=True) + bio = Column(db.String(300), nullable=True) + phone = Column(db.String(300), nullable=True) + password = Column(db.Binary(128), nullable=True) + is_active = Column(db.Boolean(), default=True) + is_superuser = Column(db.Boolean(), default=False) + + def __init__(self, username, email, password=None, **kwargs): + """Create instance.""" + db.Model.__init__(self, username=username, email=email, **kwargs) + if password: + self.set_password(password) + else: + self.password = None + + def set_password(self, password): + """Set password.""" + self.password = bcrypt.generate_password_hash(password) + + def check_password(self, value): + """Check password.""" + return bcrypt.check_password_hash(self.password, value) + + def __repr__(self): + """Represent instance as a unique string.""" + return "".format(username=self.username) + + +class Board(PKMixin, DTMixin, Model): + + __tablename__ = "boards" + __table_args__ = ( + db.UniqueConstraint("name", "owner_uuid", name="unique_name_owner_board"), + ) + + name = Column(db.String(80), nullable=False) + description = Column(db.String(255), unique=False, nullable=True, index=True) + owner_uuid = reference_col("users", pk_name="uuid", nullable=False) + owner = relationship("User", backref=db.backref("boards")) + is_active = Column(db.Boolean(), default=True) + + def __init__(self, *args, **kwargs): + """Create instance.""" + db.Model.__init__(self, *args, **kwargs) + + def __repr__(self): + """Represent instance as a unique string.""" + return "".format(name=self.name) + + +class Permission(PKMixin, DTMixin, Model): + + __tablename__ = "permissions" + __table_args__ = ( + db.UniqueConstraint( + "board_uuid", "user_uuid", name="unique_board_owner_permission" + ), + ) + + class Role(XEnum): + OWNER = 1, _("Owner"), _("Owner of board(creator)") + EDITOR = 2, _("Editor"), _("Editor of board") + STAFF = 3, _("Staff"), _("Ordinary user") + + role = Column(db.Integer(), default=Role.STAFF.value) + user_uuid = reference_col("users", pk_name="uuid", nullable=False) + user = relationship("User", backref=db.backref("perms")) + board_uuid = reference_col("boards", pk_name="uuid", nullable=False) + board = relationship("Board", backref=db.backref("perms")) + + def __init__(self, *args, **kwargs): + """Create instance.""" + db.Model.__init__(self, *args, **kwargs) + + def __repr__(self): + """Represent instance as a unique string.""" + return "".format(self.uuid) + + +class BoardColumn(PKMixin, DTMixin, Model): + + __tablename__ = "columns" + __table_args__ = ( + db.UniqueConstraint( + "board_uuid", "name", name="unique_board__board_column_name" + ), + ) + + name = Column(db.String(120), nullable=False) + position = Column(db.Integer(), default=0) + board_uuid = reference_col("boards", pk_name="uuid", nullable=False) + board = relationship("Board", backref=db.backref("columns")) + + def __init__(self, *args, **kwargs): + """Create instance.""" + db.Model.__init__(self, *args, **kwargs) + + def __repr__(self): + """Represent instance as a unique string.""" + return "".format(self.uuid) + + +users_tasks = db.Table( + "users_tasks", + Column("task_uuid", UUID, db.ForeignKey("tasks.uuid"), primary_key=True), + Column("user_uuid", UUID, db.ForeignKey("users.uuid"), primary_key=True), +) + + +class Task(PKMixin, DTMixin, Model): + + __tablename__ = "tasks" + + class State(XEnum): + NEW = 1, _("New"), _("New") + IN_PROCESS = 2, _("In process"), _("In process") + REJECTED = 3, _("Rejected"), _("Rejected") + DONE = 4, _("Done"), _("Done") + + expire_at = db.Column(db.DateTime, nullable=True, default=None) + label = Column(db.String(80), default="") + name = Column(db.String(120), nullable=False) + description = Column(db.String(120), nullable=False) + state = Column(db.Integer(), default=State.NEW.value) + position = Column(db.Integer(), default=0) + + column_uuid = reference_col("columns", pk_name="uuid", nullable=False) + column = relationship( + "BoardColumn", backref=db.backref(name="tasks", order_by="Task.position.asc()") + ) + + creator_uuid = reference_col("users", pk_name="uuid", nullable=False) + creator = relationship("User", backref=db.backref("tasks")) + + performers = relationship( + "User", + secondary=users_tasks, + lazy="subquery", + backref=db.backref("tasks_to_perform", lazy=True), + ) + + def __init__(self, *args, **kwargs): + """Create instance.""" + db.Model.__init__(self, *args, **kwargs) + + def __repr__(self): + """Represent instance as a unique string.""" + return "".format(self.uuid) diff --git a/task_office/core/models/mixins.py b/task_office/core/models/mixins.py new file mode 100644 index 0000000..2969a2e --- /dev/null +++ b/task_office/core/models/mixins.py @@ -0,0 +1,52 @@ +import datetime as dt +import uuid as uuid + +from sqlalchemy import JSON +from sqlalchemy.dialects.postgresql import UUID + +from task_office.compat import basestring +from task_office.extensions.db import db + + +class PKMixin(object): + """ + A mixin that adds a surrogate pk(s) fields + """ + + __table_args__ = {"extend_existing": True} + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + uuid = db.Column( + UUID, primary_key=True, unique=True, default=lambda: uuid.uuid4().__str__() + ) + meta = db.Column(JSON, default=dict) + + @classmethod + def get_by_id(cls, item_id): + """Get record by ID.""" + if any( + ( + isinstance(item_id, basestring) and item_id.isdigit(), + isinstance(item_id, (int, float)), + ) + ): + return cls.query.filter_by(id=item_id).first() + + def hexed_uuid(self): + return uuid.UUID(str(self.uuid)).hex + + +class DTMixin(object): + """ + A mixin that adds a created_at, updated_at fields + """ + + __table_args__ = {"extend_existing": True} + + created_at = db.Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) + updated_at = db.Column( + db.DateTime, + nullable=False, + default=dt.datetime.utcnow, + onupdate=dt.datetime.utcnow, + ) diff --git a/task_office/core/schemas/__init__.py b/task_office/core/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/core/schemas/base_schemas.py b/task_office/core/schemas/base_schemas.py new file mode 100644 index 0000000..a39293d --- /dev/null +++ b/task_office/core/schemas/base_schemas.py @@ -0,0 +1,78 @@ +import logging +import uuid + +from marshmallow import Schema, fields, post_dump, post_load +from marshmallow.validate import Range + +from task_office.exceptions import InvalidUsage +from task_office.settings import app_config + + +class XSchema(Schema): + def _format_error(self, value="", msg_code="", field_name="detail"): + return { + field_name: [self.error_messages.get(msg_code, "").format(value=value)], + "msg_code": msg_code if msg_code in self.error_messages else "", + } + + def _throw_exception(self, messages, code): + raise InvalidUsage(messages=messages, status_code=code) + + def throw_error(self, value, key_error, field_name="detail", code=422): + messages = [self._format_error(value, key_error, field_name)] + self._throw_exception(messages=messages, code=code) + + def handle_error(self, exc, data, **kwargs): + """Log and raise our custom exception when (de)serialization fails.""" + errors_data = exc.messages + logging.error(errors_data) + messages = [] + for field_name, msgs in errors_data.items(): + formatted_error = self._format_error(field_name=field_name) + formatted_error[field_name] = msgs + messages.append(formatted_error) + self._throw_exception(messages=messages, code=422) + + +class BaseSchema(XSchema): + created_at = fields.DateTime(attribute="created_at", dump_only=True) + updated_at = fields.DateTime(attribute="updated_at", dump_only=True) + uuid = fields.UUID(dump_only=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + +class ListSchema(XSchema): + + limit = fields.Integer( + default=app_config.DEFAULT_LIMIT_VALUE, + required=False, + validate=[Range(min=1, max=app_config.MAX_LIMIT_VALUE)], + ) + offset = fields.Integer( + default=app_config.DEFAULT_OFFSET_VALUE, required=False, validate=[Range(min=0)] + ) + + +list_schema = ListSchema() + + +class SearchSchema(XSchema): + FIELDS_MAP = {} + + @post_load + def post_load_data(self, data, **kwargs): + res = {} + for k, v in data.items(): + k_separated = k.split("__") + key = k_separated[0] + key_mapped = self.FIELDS_MAP[key] + lookup = k_separated[1] if "__" in k else "" + res[key] = {"value": v, "lookup": lookup, "key": key_mapped} + return res + + +search_schema = SearchSchema() diff --git a/task_office/core/schemas/nested_schemas.py b/task_office/core/schemas/nested_schemas.py new file mode 100644 index 0000000..8e1b85e --- /dev/null +++ b/task_office/core/schemas/nested_schemas.py @@ -0,0 +1,46 @@ +import uuid + +from marshmallow import fields, post_dump + +from task_office.core.schemas.base_schemas import XSchema +from task_office.settings import app_config + + +class NestedUserDumpSchema(XSchema): + uuid = fields.UUID(dump_only=True) + username = fields.Str(dump_only=True) + email = fields.Email(dump_only=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + class Meta: + strict = True + + +nested_user_dump_schema = NestedUserDumpSchema() +nested_user_list_dump_schema = NestedUserDumpSchema(many=True) + + +class NestedColumnDumpSchema(XSchema): + uuid = fields.UUID(dump_only=True) + name = fields.Str(dump_only=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + class Meta: + strict = True + + +nested_column_dump_schema = NestedColumnDumpSchema() + + +app_config.API_SPEC.components.schema("NestedUserSchema", schema=NestedUserDumpSchema) +app_config.API_SPEC.components.schema( + "NestedColumnDumpSchema", schema=NestedColumnDumpSchema +) diff --git a/task_office/core/utils.py b/task_office/core/utils.py new file mode 100644 index 0000000..42c1682 --- /dev/null +++ b/task_office/core/utils.py @@ -0,0 +1,68 @@ +import string +import random +from typing import Any, Union, Tuple +from uuid import UUID + +from flask import request +from flask_babel import lazy_gettext as _ +from flask_sqlalchemy import BaseQuery + +from task_office.core.constants import LOOKUP_MAP +from task_office.exceptions import InvalidUsage +from task_office.extensions import db + +Model = db.Model + + +def _query(model: Model, **params) -> BaseQuery: + return model.query.filter_by(**params) + + +def non_empty_query_required(model: Model, **params) -> (BaseQuery, Model): + qs = _query(model, **params) + obj_first = qs.first() + if not obj_first: + raise InvalidUsage(messages=[_("Not found")], status_code=404) + return qs, obj_first + + +def empty_query_required(model: Model, **params) -> (BaseQuery, Model): + qs = _query(model, **params) + obj_first = qs.first() + if obj_first: + raise InvalidUsage(messages=[_("Already exists")], status_code=422) + return qs, obj_first + + +def lookup_filter( + query: BaseQuery, key: str, value: Any, lookup: str = "" +) -> BaseQuery: + return LOOKUP_MAP.get(lookup, LOOKUP_MAP.get("e"))(query, key, value) + + +def validate_request_url_uuid( + model: Model, key: str, uuid: str, must_exists: bool = False +) -> Union[Tuple, None]: + if not is_uuid(uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + + request_url_separated = request.url.split("/") + if uuid not in request_url_separated: + raise InvalidUsage(messages=[_("Not found")], status_code=404) + + if must_exists: + return non_empty_query_required(model, **{key: uuid}) + + return None + + +def generate_str(size=6, chars=string.ascii_uppercase + string.digits): + return "".join(random.choice(chars) for _ in range(size)) + + +def is_uuid(uuid) -> bool: + try: + UUID(uuid).version + return True + except ValueError: + return False diff --git a/task_office/core/validators.py b/task_office/core/validators.py new file mode 100644 index 0000000..578412b --- /dev/null +++ b/task_office/core/validators.py @@ -0,0 +1,57 @@ +import typing +from flask_babel import lazy_gettext as _ +from marshmallow import ValidationError +from marshmallow.validate import Validator + + +class Unique(Validator): + """Validator which is entity exists by some field.""" + + already_exists = _("Already exists with value {}") + + def __init__(self, model: object, field_name: str): + + self.model = model + self.field_name = field_name + + def _repr_args(self) -> str: + return "model={!r}, field_name={!r}".format(self.model, self.field_name) + + def _format_error(self, value, message: str) -> str: + return message.format(value) + + def __call__(self, value) -> typing.Any: + param = {self.field_name: value} + + obj = self.model.query.filter_by(**param).first() + + if obj: + message = self.already_exists + raise ValidationError(self._format_error(value, message)) + return value + + +class PKExists(Validator): + """Validator of entity pk.""" + + not_found = _("Not found with value {}") + + def __init__(self, model: object, field_name: str = "uuid"): + + self.model = model + self.field_name = field_name + + def _repr_args(self) -> str: + return "model={!r}, field_name={!r}".format(self.model, self.field_name) + + def _format_error(self, value, message: str) -> str: + return message.format(value) + + def __call__(self, value) -> typing.Any: + param = {self.field_name: str(value)} + obj = self.model.query.filter_by(**param).first() + + if not obj: + message = self.not_found + raise ValidationError(self._format_error(value, message)) + return value diff --git a/task_office/core/views.py b/task_office/core/views.py new file mode 100644 index 0000000..3ddd172 --- /dev/null +++ b/task_office/core/views.py @@ -0,0 +1,11 @@ +"""Core views.""" +from datetime import datetime + +from flask import Blueprint + +bp = Blueprint("", __name__, url_prefix="") + + +@bp.route("/", methods=("get",)) +def root(): + return {"datetime": datetime.utcnow()} diff --git a/task_office/database.py b/task_office/database.py new file mode 100644 index 0000000..664d2dd --- /dev/null +++ b/task_office/database.py @@ -0,0 +1,24 @@ +"""Database module, including the SQLAlchemy database object and DB-related utilities.""" + +from sqlalchemy.orm import relationship + +from .extensions.db import db + +# Alias common SQLAlchemy names +Column = db.Column +Enum = db.Enum +relationship = relationship +Model = db.Model + + +def reference_col(tablename, nullable=False, pk_name="uuid", **kwargs): + """Column that adds primary key foreign key reference. + + Usage: :: + + category_id = reference_col('category') + category = relationship('Category', backref='categories') + """ + return db.Column( + db.ForeignKey("{0}.{1}".format(tablename, pk_name)), nullable=nullable, **kwargs + ) diff --git a/task_office/exceptions.py b/task_office/exceptions.py new file mode 100644 index 0000000..3731374 --- /dev/null +++ b/task_office/exceptions.py @@ -0,0 +1,30 @@ +from flask import jsonify + + +def template(data, code=500): + return {"messages": {"errors": data}, "status_code": code} + + +UNKNOWN_ERROR = template([], code=500) + + +class InvalidUsage(Exception): + status_code = 500 + + def __init__(self, messages, status_code=500, key=None): + Exception.__init__(self) + self.key = key + payload = messages + if self.key: + payload = {self.key: messages} + self.messages = template(data=payload, code=status_code) + if status_code is not None: + self.status_code = status_code + + def to_json(self): + rv = self.messages + return jsonify(rv) + + @classmethod + def unknown_error(cls): + return cls(**UNKNOWN_ERROR) diff --git a/task_office/extensions/__init__.py b/task_office/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/extensions/babel.py b/task_office/extensions/babel.py new file mode 100644 index 0000000..7d4c6f6 --- /dev/null +++ b/task_office/extensions/babel.py @@ -0,0 +1,25 @@ +# Babel +# https://pythonhosted.org/Flask-Babel/ +# ------------------------------------------------------------------------------ +from flask import request, g +from flask_babel import Babel + +from task_office.settings import app_config + +babel = Babel(default_locale=app_config.LOCALE, default_timezone=app_config.TIME_ZONE) + + +@babel.localeselector +def get_locale(): + # if a user is logged in, use the locale from the user settings + user = getattr(g, "user", None) + if user is not None: + return user.locale + return request.accept_languages.best_match(list(app_config.LANGUAGES.keys())) + + +@babel.timezoneselector +def get_timezone(): + user = getattr(g, "user", None) + if user is not None: + return user.timezone diff --git a/task_office/extensions/bcrypt.py b/task_office/extensions/bcrypt.py new file mode 100644 index 0000000..ac60495 --- /dev/null +++ b/task_office/extensions/bcrypt.py @@ -0,0 +1,3 @@ +from flask_bcrypt import Bcrypt + +bcrypt = Bcrypt() diff --git a/task_office/extensions/cache.py b/task_office/extensions/cache.py new file mode 100644 index 0000000..c017dec --- /dev/null +++ b/task_office/extensions/cache.py @@ -0,0 +1,3 @@ +from flask_caching import Cache + +cache = Cache() diff --git a/task_office/extensions/cors.py b/task_office/extensions/cors.py new file mode 100644 index 0000000..cd66f9d --- /dev/null +++ b/task_office/extensions/cors.py @@ -0,0 +1,9 @@ +from flask import Flask +from flask_cors import CORS + + +def init_cors(app: Flask): + cors = CORS( + app, + resources={r"/*": {"origins": app.config.get("CORS_ORIGIN_WHITELIST", "*")}}, + ) diff --git a/task_office/extensions/db.py b/task_office/extensions/db.py new file mode 100644 index 0000000..837f35f --- /dev/null +++ b/task_office/extensions/db.py @@ -0,0 +1,32 @@ +from flask_sqlalchemy import SQLAlchemy, Model + + +class CRUDMixin(Model): + """Mixin that adds convenience methods for CRUD (create, read, update, delete) operations.""" + + @classmethod + def create(cls, **kwargs): + """Create a new item and save it the database.""" + instance = cls(**kwargs) + return instance.save() + + def update(self, commit=True, **kwargs): + """Update specific fields of a item.""" + for attr, value in kwargs.items(): + setattr(self, attr, value) + return commit and self.save() or self + + def save(self, commit=True): + """Save the item.""" + db.session.add(self) + if commit: + db.session.commit() + return self + + def delete(self, commit=True): + """Remove the item from the database.""" + db.session.delete(self) + return commit and db.session.commit() + + +db = SQLAlchemy(model_class=CRUDMixin) diff --git a/task_office/extensions/jwt.py b/task_office/extensions/jwt.py new file mode 100644 index 0000000..13e897d --- /dev/null +++ b/task_office/extensions/jwt.py @@ -0,0 +1,18 @@ +from flask import Flask +from flask_jwt_extended import JWTManager + + +def _jwt_identity(identifier): + from task_office.auth import User + + return User.get_by_id(identifier) + + +def _identity_loader(user): + return user.id + + +def init_jwt(app: Flask): + jwt = JWTManager(app=app) + jwt.user_loader_callback_loader(_jwt_identity) + jwt.user_identity_loader(_identity_loader) diff --git a/task_office/extensions/migrate.py b/task_office/extensions/migrate.py new file mode 100644 index 0000000..022cc5d --- /dev/null +++ b/task_office/extensions/migrate.py @@ -0,0 +1,7 @@ +from flask import Flask +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + + +def init_migrate(app: Flask, db: SQLAlchemy): + migrate = Migrate(app=app, db=db) diff --git a/task_office/permissions/__init__.py b/task_office/permissions/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/permissions/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/permissions/constants.py b/task_office/permissions/constants.py new file mode 100644 index 0000000..2121431 --- /dev/null +++ b/task_office/permissions/constants.py @@ -0,0 +1,4 @@ +from task_office.boards.constants import APP_PREFIX_RETRIEVE as BOARD_PREFIX_RETRIEVE + +APP_PREFIX = BOARD_PREFIX_RETRIEVE + "/permissions" +APP_PREFIX_RETRIEVE = APP_PREFIX + "/" diff --git a/task_office/permissions/schemas/__init__.py b/task_office/permissions/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/permissions/schemas/basic_schemas.py b/task_office/permissions/schemas/basic_schemas.py new file mode 100644 index 0000000..4654e4e --- /dev/null +++ b/task_office/permissions/schemas/basic_schemas.py @@ -0,0 +1,79 @@ +import uuid + +from marshmallow import fields, validates_schema, post_dump +from marshmallow_enum import EnumField + +from task_office.auth import User +from task_office.core.enums import XEnum, OrderingDirection +from task_office.core.models.db_models import Permission +from task_office.core.schemas.base_schemas import BaseSchema, ListSchema, XSchema +from task_office.core.schemas.nested_schemas import NestedUserDumpSchema +from task_office.core.validators import PKExists +from ...settings import app_config + + +class PermissionQuerySchema(BaseSchema): + role = EnumField(Permission.Role, required=True, by_value=True) + user_uuid = fields.UUID( + required=True, validate=[PKExists(User, "uuid")], allow_none=False + ) + + class Meta: + strict = True + + @validates_schema + def validate_schema(self, data, **kwargs): + data["user_uuid"] = str(data.pop("user_uuid")) + data["role"] = data.pop("role").value + + +class PermissionDumpSchema(BaseSchema): + role = fields.Integer(dump_only=True) + board_uuid = fields.UUID(dump_only=True) + user = fields.Nested(NestedUserDumpSchema, dump_only=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["board_uuid"] = uuid.UUID(data.pop("board_uuid")).hex + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + class Meta: + strict = True + + +permission_query_schema = PermissionQuerySchema() +permission_dump_schema = PermissionDumpSchema() +permission_list_dump_schema = PermissionDumpSchema(many=True) +app_config.API_SPEC.components.schema( + "PermissionQuerySchema", schema=PermissionQuerySchema +) +app_config.API_SPEC.components.schema( + "PermissionDumpSchema", schema=PermissionDumpSchema +) + + +class PermissionListQuerySchema(ListSchema): + class OrderingMap(XEnum): + CREATED_AT_ASC = ( + "-created_at", + Permission.created_at.asc(), + OrderingDirection.ASC, + ) + CREATED_AET_DESC = ( + "created_at", + Permission.created_at.desc(), + OrderingDirection.DESC, + ) + + searching = fields.Nested(XSchema, required=False) + ordering = EnumField(OrderingMap, required=False, by_value=True) + + class Meta: + strict = True + + +permissions_list_query_schema = PermissionListQuerySchema() +app_config.API_SPEC.components.schema( + "PermissionListQuerySchema", schema=PermissionListQuerySchema +) diff --git a/task_office/permissions/views.py b/task_office/permissions/views.py new file mode 100644 index 0000000..684da8b --- /dev/null +++ b/task_office/permissions/views.py @@ -0,0 +1,149 @@ +"""Permissions views.""" +import uuid +from datetime import datetime + +from flask_apispec import use_kwargs, marshal_with +from flask_babel import lazy_gettext as _ +from flask_jwt_extended import jwt_required + +from .constants import APP_PREFIX, APP_PREFIX_RETRIEVE +from .schemas.basic_schemas import ( + permission_query_schema, + permission_dump_schema, + permissions_list_query_schema, + permission_list_dump_schema, +) +from ..api.v1.views import bp +from ..auth.utils import permission, reset_permissions +from ..core.helpers.listed_response import listed_response +from ..core.models.db_models import Permission, Board +from ..core.utils import is_uuid, non_empty_query_required, empty_query_required +from ..exceptions import InvalidUsage + + +@bp.route(APP_PREFIX + "/meta", methods=("get",)) +@jwt_required +@permission(required_role=Permission.Role.EDITOR.value) +def get_permissions_meta_data(board_uuid): + """ + Additional data for Permissions + """ + if not is_uuid(board_uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + non_empty_query_required(Permission, board_uuid=str(board_uuid)) + + data = dict() + data["roles"] = Permission.Role.dict_choices() + return data + + +@bp.route(APP_PREFIX, methods=("post",)) +@jwt_required +@use_kwargs(permission_query_schema) +@marshal_with(permission_dump_schema) +@permission(required_role=Permission.Role.EDITOR.value) +def create_permission(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + # Check board_uuid in request_url + if not is_uuid(board_uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + non_empty_query_required(Board, uuid=str(board_uuid)) + + # Check board_uuid, user_uuid are unique for permission + empty_query_required(Permission, board_uuid=board_uuid, user_uuid=data["user_uuid"]) + + role = data["role"] + if role == Permission.Role.OWNER.value: + if Permission.query.filter_by( + board_uuid=board_uuid, role=Permission.Role.OWNER.value + ).first(): + raise InvalidUsage(messages=[_("Not allowed")], status_code=422) + + perm = Permission(board_uuid=board_uuid, **data) + perm.save() + reset_permissions(uuid.UUID(perm.user_uuid).hex) + return perm + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("put",)) +@jwt_required +@use_kwargs(permission_query_schema) +@marshal_with(permission_dump_schema) +@permission(required_role=Permission.Role.EDITOR.value) +def update_permission(board_uuid, permission_uuid, **kwargs): + """ + :param board_uuid: + :param permission_uuid: + :param kwargs: + :return: + """ + data = kwargs + # Check is valid board uuid and permission_uuid in request url + if not is_uuid(board_uuid) or not is_uuid(permission_uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + + role = data["role"] + if role == Permission.Role.OWNER.value: + if Permission.query.filter_by( + board_uuid=board_uuid, role=Permission.Role.OWNER.value + ).first(): + raise InvalidUsage(messages=[_("Not allowed")], status_code=422) + + perm = non_empty_query_required( + Permission, uuid=str(permission_uuid), board_uuid=str(board_uuid) + )[1] + + perm.update(updated_at=datetime.utcnow(), **data) + reset_permissions(uuid.UUID(perm.user_uuid).hex) + return perm + + +@bp.route(APP_PREFIX, methods=("get",)) +@jwt_required +@use_kwargs(permissions_list_query_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_list_permission(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + # Check board_uuid in request_url + if not is_uuid(board_uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + + perms = non_empty_query_required(Permission, board_uuid=str(board_uuid))[0] + + # Serialize to paginated response + data = listed_response.serialize( + query=perms, query_params=data, schema=permission_list_dump_schema + ) + return data + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("get",)) +@jwt_required +@marshal_with(permission_dump_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_permission_by_uuid(board_uuid, permission_uuid): + """ + :param board_uuid: + :param permission_uuid: + :return: + """ + # Check is valid board uuid and permission_uuid in request url + if not is_uuid(board_uuid) or not is_uuid(permission_uuid): + raise InvalidUsage(messages=[_("Not found")], status_code=404) + + perm = non_empty_query_required( + Permission, uuid=str(permission_uuid), board_uuid=str(board_uuid) + ) + perm = perm[1] + + return perm diff --git a/task_office/settings.py b/task_office/settings.py new file mode 100644 index 0000000..d3d8745 --- /dev/null +++ b/task_office/settings.py @@ -0,0 +1,120 @@ +"""Application configuration.""" +import os +from datetime import timedelta + +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from apispec_webframeworks.flask import FlaskPlugin +from environs import Env + +env = Env() + + +class Config(object): + """Base configuration.""" + + # PROJECT DIRS + APP_DIR = os.path.abspath(os.path.dirname(__file__)) + PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir)) + + READ_DOT_ENV_FILE = env.bool("FLASK_READ_DOT_ENV_FILE", default=True) + if READ_DOT_ENV_FILE: + # OS environment variables take precedence over variables from .env + env.read_env(os.path.join(PROJECT_ROOT, ".env")) + + # GENERAL + PROJECT_NAME = env.str("PROJECT_NAME", "NoName") + SECRET_KEY = env.str("FLASK_SECRET", "NoKey") + FLASK_DEBUG = env.int("FLASK_DEBUG", 0) + TIME_ZONE = "UTC" + + LANGUAGES = {"ru": "Russian", "en": "English", "uk": "Ukrainian"} + LOCALE = "en" + # https://pythonhosted.org/Flask-Babel/ + BABEL_TRANSLATION_DIRECTORIES = os.path.join(PROJECT_ROOT, "translations") + + # API + API_PREFIX = "/api/v1" + USE_DOCS = env.bool("USE_DOCS", False) + + API_SPEC = APISpec( + openapi_version="3.0.2", + title=PROJECT_NAME, + version="1.0.0", + info=dict(description=f"{PROJECT_NAME} API"), + plugins=[FlaskPlugin(), MarshmallowPlugin()], + ) + + # API PAGINATION + DEFAULT_OFFSET_VALUE = 0 + DEFAULT_LIMIT_VALUE = 15 + MAX_LIMIT_VALUE = 50 + + CORS_ORIGIN_WHITELIST = env.list("CORS_ORIGIN_WHITELIST", []) + + # STATIC DIRS + STATIC_DIR = os.path.abspath(os.path.join(PROJECT_ROOT, "static")) + STATIC_URL = API_PREFIX + "/static" + + # JWT + JWT_AUTH_USERNAME_KEY = "uuid" + JWT_AUTH_HEADER_PREFIX = "Bearer" + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + minutes=env.int("JWT_ACCESS_TOKEN_EXPIRES", 30) + ) + JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=env.int("JWT_REFRESH_TOKEN_EXPIRES", 7)) + JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] + + # DB + SQLALCHEMY_TRACK_MODIFICATIONS = False + DATABASE = { + "DB_NAME": env.str("POSTGRES_DB", "task_office"), + "DB_USER": env.str("POSTGRES_USER", "task_office_user"), + "DB_PASSWORD": env.str("POSTGRES_PASSWORD", "task_office_user"), + "DB_HOST": env.str("POSTGRES_HOST", "127.0.0.1"), + "DB_PORT": env.int("POSTGRES_PORT", "5432"), + } + SQLALCHEMY_DATABASE_URI = "postgresql://{username}:{password}@{host}:{db_port}/{db_name}".format( + username=DATABASE["DB_USER"], + password=DATABASE["DB_PASSWORD"], + host=DATABASE["DB_HOST"], + db_port=DATABASE["DB_PORT"], + db_name=DATABASE["DB_NAME"], + ) + + CACHE = { + "CACHE_TYPE": env.str("CACHE_TYPE", "redis"), + "CACHE_REDIS_HOST": env.str("CACHE_REDIS_HOST"), + "CACHE_REDIS_PORT": env.int("CACHE_REDIS_PORT"), + "CACHE_REDIS_PASSWORD": env.str("CACHE_REDIS_PASSWORD"), + "CACHE_REDIS_DB": env.int("CACHE_REDIS_DB"), + "CACHE_DEFAULT_TIMEOUT": env.int( + "CACHE_DEFAULT_TIMEOUT", JWT_ACCESS_TOKEN_EXPIRES.seconds + ), + "CACHE_REDIS_URL": "redis://:{password}@{host}:{port}/{db}".format( + password=env.str("CACHE_REDIS_PASSWORD"), + host=env.str("CACHE_REDIS_HOST"), + port=env.str("CACHE_REDIS_PORT"), + db=env.str("CACHE_REDIS_DB"), + ), + "OPTIONS": {"PASSWORD": env.str("CACHE_REDIS_PASSWORD")}, + } + + +class ProdConfig(Config): + """Production configuration.""" + + +class DevConfig(Config): + """Development configuration.""" + + +class TestConfig(Config): + """Test configuration.""" + + +MODE = os.environ.get("MODE", default="dev") + +CONFIG_SETS = {"prod": ProdConfig, "dev": DevConfig, "test": TestConfig} + +app_config = CONFIG_SETS.get(MODE) diff --git a/task_office/swagger/__init__.py b/task_office/swagger/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/swagger/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/swagger/api_paths.py b/task_office/swagger/api_paths.py new file mode 100644 index 0000000..3e579e7 --- /dev/null +++ b/task_office/swagger/api_paths.py @@ -0,0 +1,521 @@ +API_PATHS = { + # Auth + "/api/v1/auth/sign-up": { + "post": { + "tags": ["Auth"], + "summary": "Sign Up, Create new User", + "requestBody": { + "description": "Sign Up Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/UserSignUpSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "201": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/UserSchema"}, + }, + "400": {"description": "Failed. Bad post data."}, + "422": {"description": "Failed. Bad post data."}, + }, + } + }, + "/api/v1/auth/sign-in": { + "post": { + "tags": ["Auth"], + "summary": "Sign In User", + "requestBody": { + "description": "Sign In Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/UserSignInSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "201": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/SignedSchema"}, + }, + "400": {"description": "Failed. Bad post data."}, + "422": {"description": "Failed. Bad post data."}, + }, + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": ["Auth"], + "summary": "Refresh Access token", + "requestBody": { + "description": "Refresh Post Object", + "required": True, + "content": {"application/json": {}}, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": { + "$ref": "#/components/schemas/RefreshedAccessTokenSchema" + }, + }, + "400": {"description": "Failed. Bad post data."}, + "401": {"description": "Failed. Bad post data."}, + "422": {"description": "Failed. Bad post data."}, + }, + } + }, + # Boards + "/api/v1/boards": { + "post": { + "tags": ["Boards"], + "summary": "Boards create", + "requestBody": { + "description": "Boards Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/BoardActionsSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/BoardDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "get": { + "tags": ["Boards"], + "summary": "Returns Boards", + "requestBody": { + "description": "Boards get Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/BoardListQuerySchema"} + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/BoardDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards/": { + "get": { + "tags": ["Boards"], + "summary": "Returns Boards", + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/BoardDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "put": { + "tags": ["Boards"], + "summary": "Boards update", + "requestBody": { + "description": "Boards Put Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/BoardActionsSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/BoardDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//users": { + "get": { + "tags": ["Boards"], + "summary": "Returns Users of boards", + "requestBody": { + "description": "Users get Object", + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserListByBoardQuerySchema" + } + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/NestedUserDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, + "/api/v1/boards//columns": { + "post": { + "tags": ["Columns"], + "summary": "Boards Columns create", + "requestBody": { + "description": "Boards Columns Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ColumnPostSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/ColumnDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "get": { + "tags": ["Columns"], + "summary": "Returns Boards Columns", + "requestBody": { + "description": "Boards Columns Get Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ColumnListQuerySchema"} + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/ColumnDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//columns/meta": { + "get": { + "tags": ["Columns"], + "summary": "Returns Boards Columns Metadata", + "requestBody": { + "description": "Boards Columns Get Metadata", + "required": True, + "content": {"application/json": {}}, + }, + "responses": { + "200": {"description": "OK"}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//columns/": { + "put": { + "tags": ["Columns"], + "summary": "Boards Columns Update", + "requestBody": { + "description": "Update Boards Columns Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ColumnPutSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/ColumnDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "404": {"description": "Failed. Not found."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, + # Tasks + "/api/v1/boards//tasks": { + "post": { + "tags": ["Tasks"], + "summary": "Tasks create", + "requestBody": { + "description": "Tasks Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TaskPostSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/TaskDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "get": { + "tags": ["Tasks"], + "summary": "Returns Tasks", + "requestBody": { + "description": "Tasks Get list of Objects", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TaskListQuerySchema"} + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/TaskDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//tasks/": { + "put": { + "tags": ["Tasks"], + "summary": "Tasks Update", + "requestBody": { + "description": "Tasks Put Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TaskPutSchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/TaskDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, + "/api/v1/boards//tasks/by-columns": { + "get": { + "tags": ["Tasks"], + "summary": "Returns Tasks", + "requestBody": { + "description": "Tasks by columns Get list of Objects", + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListByColumnsQuerySchema" + } + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/TaskDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, + "/api/v1/boards//tasks/meta": { + "get": { + "tags": ["Tasks"], + "summary": "Returns additional tasks info", + "responses": { + "200": {"description": "OK", "schema": {}}, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, + # Permissions + "/api/v1/boards//permissions": { + "post": { + "tags": ["Permissions"], + "summary": "Permissions create", + "requestBody": { + "description": "Permissions Post Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PermissionQuerySchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/ PermissionDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "get": { + "tags": ["Permissions"], + "summary": "Returns Permissions", + "requestBody": { + "description": "Permissions get list of Objects", + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionListQuerySchema" + } + } + }, + }, + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/PermissionDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//permissions/": { + "put": { + "tags": ["Permissions"], + "summary": "Permissions update", + "requestBody": { + "description": "Permissions Put Object", + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PermissionQuerySchema"} + } + }, + }, + "produces": ["application/json"], + "responses": { + "200": { + "required": True, + "description": "OK", + "schema": {"$ref": "#/components/schemas/ PermissionDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + "get": { + "tags": ["Permissions"], + "summary": "Returns Permissions", + "responses": { + "200": { + "description": "OK", + "schema": {"$ref": "#/components/schemas/PermissionDumpSchema"}, + }, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + }, + }, + "/api/v1/boards//permissions/meta": { + "get": { + "tags": ["Permissions"], + "summary": "Returns additions permissions info", + "responses": { + "200": {"description": "OK", "schema": {}}, + "400": {"description": "Failed. Bad data."}, + "401": {"description": "Failed. Not authorized."}, + "403": {"description": "Failed. Not denied."}, + "422": {"description": "Failed. Bad data."}, + }, + } + }, +} diff --git a/task_office/swagger/views.py b/task_office/swagger/views.py new file mode 100644 index 0000000..f3685e2 --- /dev/null +++ b/task_office/swagger/views.py @@ -0,0 +1,28 @@ +"""Swagger views.""" + +from flask import Blueprint, jsonify +from flask_swagger_ui import get_swaggerui_blueprint + +from task_office.settings import app_config +from task_office.swagger.api_paths import API_PATHS + +APP_PREFIX = "/docs" +SWAGGER_URL = app_config.API_PREFIX + APP_PREFIX +API_URL = SWAGGER_URL + "/open-api" + + +bp = Blueprint("docs", __name__, url_prefix=SWAGGER_URL) +bp_swagger = get_swaggerui_blueprint( + SWAGGER_URL, API_URL, config={"app_name": app_config.PROJECT_NAME} +) + + +@bp.route("/open-api", methods=("get",)) +def api_swagger(**kwargs): + """ + :param kwargs: + :return: + """ + data = app_config.API_SPEC.to_dict() + data["paths"] = API_PATHS + return jsonify(data) diff --git a/task_office/tasks/__init__.py b/task_office/tasks/__init__.py new file mode 100644 index 0000000..6b274ab --- /dev/null +++ b/task_office/tasks/__init__.py @@ -0,0 +1 @@ +from .views import * diff --git a/task_office/tasks/constants.py b/task_office/tasks/constants.py new file mode 100644 index 0000000..336d8b3 --- /dev/null +++ b/task_office/tasks/constants.py @@ -0,0 +1,4 @@ +from task_office.boards.constants import APP_PREFIX_RETRIEVE as BOARD_PREFIX_RETRIEVE + +APP_PREFIX = BOARD_PREFIX_RETRIEVE + "/tasks" +APP_PREFIX_RETRIEVE = APP_PREFIX + "/" diff --git a/task_office/tasks/schemas/__init__.py b/task_office/tasks/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_office/tasks/schemas/basic_schemas.py b/task_office/tasks/schemas/basic_schemas.py new file mode 100644 index 0000000..a91043b --- /dev/null +++ b/task_office/tasks/schemas/basic_schemas.py @@ -0,0 +1,191 @@ +import uuid + +from flask_babel import lazy_gettext as _ +from marshmallow import fields, post_dump, validates_schema, pre_load +from marshmallow.validate import Length, Range +from marshmallow_enum import EnumField + +from task_office.core.enums import XEnum +from task_office.core.models.db_models import BoardColumn, Task, User +from task_office.core.schemas.base_schemas import BaseSchema, ListSchema, SearchSchema +from task_office.core.schemas.nested_schemas import NestedUserDumpSchema +from task_office.core.validators import PKExists +from task_office.settings import app_config +from task_office.tasks.schemas.search_schemas import SearchTaskSchema + + +class TaskPostSchema(BaseSchema): + expire_at = fields.DateTime(required=False, allow_none=False, default=None) + label = fields.Str( + required=False, allow_none=False, validate=[Length(max=80)], default="" + ) + name = fields.Str( + required=True, allow_none=False, validate=[Length(min=1, max=120)] + ) + description = fields.Str( + required=False, allow_none=False, validate=[Length(max=120)], default="" + ) + state = EnumField( + Task.State, + required=False, + allow_none=False, + by_value=True, + default=Task.State.NEW.value, + ) + position = fields.Integer( + required=True, default=1, allow_none=False, validate=[Range(min=1)] + ) + column_uuid = fields.UUID( + required=True, validate=[PKExists(BoardColumn, "uuid")], allow_none=False + ) + performers = fields.List( + fields.UUID( + required=False, validate=[PKExists(User, "uuid")], allow_none=False + ), + required=False, + allow_none=False, + ) + + class Meta: + strict = True + + @validates_schema + def validate_schema(self, data, **kwargs): + data["column_uuid"] = str(data.pop("column_uuid")) + data["state"] = data.pop("state", Task.State.NEW).value + if data.get("performers", None): + data["performers"] = [str(item) for item in data["performers"]] + + +class TaskPutSchema(BaseSchema): + expire_at = fields.DateTime(required=False, allow_none=False) + label = fields.Str(required=False, allow_none=False, validate=[Length(max=80)]) + name = fields.Str( + required=False, allow_none=False, validate=[Length(min=1, max=120)] + ) + description = fields.Str( + required=False, allow_none=False, validate=[Length(max=120)] + ) + state = EnumField(Task.State, required=False, allow_none=False, by_value=True) + position = fields.Integer(required=False, allow_none=False, validate=[Range(min=1)]) + column_uuid = fields.UUID( + required=True, validate=[PKExists(BoardColumn, "uuid")], allow_none=False + ) + performers = fields.List( + fields.UUID( + required=False, validate=[PKExists(User, "uuid")], allow_none=False + ), + required=False, + allow_none=False, + ) + + class Meta: + strict = True + + @validates_schema + def validate_schema(self, data, **kwargs): + data["column_uuid"] = str(data.pop("column_uuid")) + state = data.get("state", None) + if state is not None: + data["state"] = state.value + if data.get("performers", None): + data["performers"] = [str(item) for item in data["performers"]] + + +class TaskDumpSchema(BaseSchema): + expire_at = fields.DateTime(attribute="expire_at", dump_only=True) + label = fields.Str(dump_only=True) + name = fields.Str(dump_only=True) + description = fields.Str(dump_only=True) + state = fields.Integer(dump_only=True) + position = fields.Integer(dump_only=True) + column_uuid = fields.UUID(dump_only=True) + performers = fields.Nested(NestedUserDumpSchema, many=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + data["column_uuid"] = uuid.UUID(data.pop("column_uuid")).hex + return data + + class Meta: + strict = True + + +task_post_schema = TaskPostSchema() +task_put_schema = TaskPutSchema() +task_dump_schema = TaskDumpSchema() +tasks_listed_dump_schema = TaskDumpSchema(many=True) +app_config.API_SPEC.components.schema("TaskPostSchema", schema=TaskPostSchema) +app_config.API_SPEC.components.schema("TaskPutSchema", schema=TaskPutSchema) +app_config.API_SPEC.components.schema("TaskDumpSchema", schema=TaskDumpSchema) + + +class TaskListQuerySchema(ListSchema): + SEARCHING_SCHEMA = SearchTaskSchema + + class OrderingMap(XEnum): + CREATED_AT_ASC = ("-created_at", Task.created_at.asc(), _("ascending")) + CREATED_AT_DESC = ("created_at", Task.created_at.desc(), _("descending")) + POSITION_ASC = ("-position", Task.position.asc(), _("ascending")) + POSITION_DESC = ("position", Task.position.desc(), _("descending")) + + searching = fields.Nested(SEARCHING_SCHEMA, required=False) + ordering = EnumField( + OrderingMap, + required=False, + by_value=True, + default=OrderingMap.POSITION_DESC.value, + ) + + class Meta: + strict = True + + @pre_load + def preload_data(self, data, **kwargs): + data["ordering"] = data.get("ordering", self.OrderingMap.POSITION_ASC) + return data + + +task_list_query_schema = TaskListQuerySchema() +app_config.API_SPEC.components.schema("TaskListQuerySchema", schema=TaskListQuerySchema) + + +class ColumnWithTasksDumpSchema(BaseSchema): + name = fields.Str(dump_only=True) + position = fields.Integer(dump_only=True) + tasks = fields.Nested(TaskDumpSchema, many=True) + + @post_dump + def dump_data(self, data, **kwargs): + data["uuid"] = uuid.UUID(data.pop("uuid")).hex + return data + + class Meta: + strict = True + + +columns_listed_dump_schema = ColumnWithTasksDumpSchema(many=True) +app_config.API_SPEC.components.schema( + "ColumnWithTasksDumpSchema", schema=ColumnWithTasksDumpSchema +) + + +class TaskListByColumnsQuerySchema(ListSchema): + SEARCHING_SCHEMA = SearchSchema + + class OrderingMap(XEnum): + POSITION_ASC = ("-position", BoardColumn.position.asc(), _("ascending")) + POSITION_DESC = ("position", BoardColumn.position.desc(), _("descending")) + + searching = fields.Nested(SEARCHING_SCHEMA, required=False) + ordering = EnumField(OrderingMap, required=False, by_value=True) + + class Meta: + strict = True + + +task_list_by_columns_query_schema = TaskListByColumnsQuerySchema() +app_config.API_SPEC.components.schema( + "TaskListByColumnsQuerySchema", schema=TaskListByColumnsQuerySchema +) diff --git a/task_office/tasks/schemas/search_schemas.py b/task_office/tasks/schemas/search_schemas.py new file mode 100644 index 0000000..2ddc8f7 --- /dev/null +++ b/task_office/tasks/schemas/search_schemas.py @@ -0,0 +1,26 @@ +from marshmallow import fields + +from task_office.core.models.db_models import Task +from task_office.core.schemas.base_schemas import SearchSchema +from task_office.settings import app_config + + +class SearchTaskSchema(SearchSchema): + FIELDS_MAP = { + "label": Task.label, + "name": Task.name, + "description": Task.description, + "position": Task.position, + } + + label = fields.Str(required=False, allow_none=False) + name = fields.Str(required=False, allow_none=False) + description = fields.Str(required=False) + position = fields.Integer(required=False) + + class Meta: + strict = True + + +search_task_schema = SearchTaskSchema() +app_config.API_SPEC.components.schema("SearchTaskSchema", schema=SearchTaskSchema) diff --git a/task_office/tasks/utils.py b/task_office/tasks/utils.py new file mode 100644 index 0000000..491c6d8 --- /dev/null +++ b/task_office/tasks/utils.py @@ -0,0 +1,38 @@ +from task_office.core.models.db_models import Task +from task_office.database import Model +from task_office.extensions import db + + +def reset_tasks_ordering( + instance: Model, column_uuid: str, new_value: int, old_value: int = None +) -> bool: + if new_value != old_value: + if old_value is None: + # if column already created + qs = Task.query.filter( + Task.id != instance.id, + Task.column_uuid == column_uuid, + Task.position >= new_value, + ) + qs.update(dict(position=Task.position + 1)) + elif new_value > old_value: + qs = Task.query.filter( + Task.id != instance.id, + Task.column_uuid == column_uuid, + Task.position > old_value, + Task.position <= new_value, + ) + qs.update(dict(position=Task.position - 1)) + else: + Task.query.filter( + Task.id != instance.id, + Task.column_uuid == column_uuid, + Task.position < old_value, + Task.position >= new_value, + ).update(dict(position=Task.position + 1)) + + db.session.commit() + + return True + + return False diff --git a/task_office/tasks/views.py b/task_office/tasks/views.py new file mode 100644 index 0000000..cd67ebf --- /dev/null +++ b/task_office/tasks/views.py @@ -0,0 +1,227 @@ +"""Tasks views.""" +from datetime import datetime + +from flask_apispec import use_kwargs, marshal_with +from flask_babel import lazy_gettext as _ +from flask_jwt_extended import jwt_required, get_current_user +from sqlalchemy import func + +from .constants import APP_PREFIX, APP_PREFIX_RETRIEVE +from .schemas.basic_schemas import ( + task_list_query_schema, + tasks_listed_dump_schema, + task_post_schema, + task_dump_schema, + task_put_schema, + task_list_by_columns_query_schema, + columns_listed_dump_schema, +) +from .utils import reset_tasks_ordering +from ..api.v1.views import bp +from ..auth.utils import permission +from ..core.helpers.listed_response import listed_response +from ..core.models.db_models import BoardColumn, Board, Task, User, Permission +from ..core.utils import validate_request_url_uuid, non_empty_query_required +from ..exceptions import InvalidUsage +from ..extensions import db + + +@bp.route(APP_PREFIX + "/meta", methods=("get",)) +@jwt_required +@permission(required_role=Permission.Role.STAFF.value) +def get_tasks_meta_data(board_uuid): + """ + Additional data for tasks + """ + validate_request_url_uuid(Board, "uuid", board_uuid, True) + data = dict() + data["task_state_choices"] = Task.State.dict_choices() + data["task_list"] = { + "ordering_choices": task_list_query_schema.OrderingMap.dict_choices(), + "searching_choices": list( + task_list_query_schema.SEARCHING_SCHEMA.FIELDS_MAP.keys() + ), + } + data["task_list_by_columns"] = { + "ordering_choices": task_list_by_columns_query_schema.OrderingMap.dict_choices(), + "searching_choices": list( + task_list_by_columns_query_schema.SEARCHING_SCHEMA.FIELDS_MAP.keys() + ), + } + return data + + +@bp.route(APP_PREFIX, methods=("post",)) +@jwt_required +@use_kwargs(task_post_schema) +@marshal_with(task_dump_schema) +@permission(required_role=Permission.Role.EDITOR.value) +def create_task(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + + data = kwargs + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + # validate max position + data["position"] = data.get("position", 1) + position = data["position"] + if position > 1: + max_position = db.session.query(func.max(Task.position)).scalar() + max_position = 1 if max_position is None else max_position + 1 + if position > max_position: + raise InvalidUsage( + messages=[_("Must be between {} and {}".format(1, max_position))], + status_code=422, + key="position", + ) + + # validate unique task name for board + q_by_name = ( + BoardColumn.query.filter(board_uuid == board_uuid) + .join(Task) + .filter(Task.name == data["name"]) + .first() + ) + if q_by_name: + raise InvalidUsage( + messages=[_("Already exists with value {}".format(data["name"]))], + status_code=422, + key="name", + ) + + # set creator(current user) of task + user = get_current_user() + user_uuid = str(user.uuid) if user else None + data["creator_uuid"] = user_uuid + + # getting performers before task save + if data.get("performers", None): + performers = User.query.filter(User.uuid.in_(data["performers"])).all() + data["performers"] = performers + + # save task + task = Task(**data) + task.save() + + # reset tasks ordering fo column + reset_tasks_ordering(task, data["column_uuid"], position) + + return task + + +@bp.route(APP_PREFIX_RETRIEVE, methods=("put",)) +@jwt_required +@use_kwargs(task_put_schema) +@marshal_with(task_dump_schema) +@permission(required_role=Permission.Role.EDITOR.value) +def update_task(board_uuid, task_uuid, **kwargs): + """ + :param board_uuid: + :param task_uuid: + :param kwargs: + :return: + """ + data = kwargs + validate_request_url_uuid(Board, "uuid", board_uuid, True) + validate_request_url_uuid(Task, "uuid", task_uuid, True) + + # validate max position value + position = data.get("position", None) + if position is not None and position > 1: + max_position = db.session.query(func.max(Task.position)).scalar() + max_position = 1 if max_position is None else max_position + 1 + + if position > max_position: + raise InvalidUsage( + messages=[_("Must be between {} and {}.".format(1, max_position))], + status_code=422, + key="position", + ) + + # get and check is required instance exists + task = non_empty_query_required( + Task, uuid=task_uuid, column_uuid=data["column_uuid"] + )[1] + + # validate unique task name for board + name = data.get("name", None) + if name is not None and name != task.name: + q_by_name = ( + BoardColumn.query.filter(board_uuid == board_uuid) + .join(Task) + .filter(Task.name == data["name"]) + .first() + ) + if q_by_name: + raise InvalidUsage( + messages=[_("Already exists with value {}".format(data["name"]))], + status_code=422, + key="name", + ) + + if data: + # getting performers before task save + if data.get("performers", None): + performers = User.query.filter(User.uuid.in_(data["performers"])).all() + data["performers"] = performers + + old_position = task.position + + task.update(updated_at=datetime.utcnow(), **data) + task.save() + + if position is not None and position > 1: + reset_tasks_ordering(task, data["column_uuid"], position, old_position) + + return task + + +@bp.route(APP_PREFIX, methods=("get",)) +@jwt_required +@use_kwargs(task_list_query_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_list_tasks(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + + # Check board_uuid in request_url + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + tasks = Task.query.join(BoardColumn).filter(BoardColumn.board_uuid == board_uuid) + + # Serialize to paginated response + data = listed_response.serialize( + query=tasks, query_params=data, schema=tasks_listed_dump_schema + ) + return data + + +@bp.route(APP_PREFIX + "/by-columns", methods=("get",)) +@jwt_required +@use_kwargs(task_list_by_columns_query_schema) +@permission(required_role=Permission.Role.STAFF.value) +def get_list_tasks_by_columns(board_uuid, **kwargs): + """ + :param board_uuid: + :param kwargs: + :return: + """ + data = kwargs + + # Check board_uuid in request_url + validate_request_url_uuid(Board, "uuid", board_uuid, True) + + columns_with_tasks = BoardColumn.query.filter(BoardColumn.board_uuid == board_uuid) + # Serialize to paginated response + data = listed_response.serialize( + query=columns_with_tasks, query_params=data, schema=columns_listed_dump_schema + ) + return data diff --git a/tests/TODO.rst b/tests/TODO.rst new file mode 100644 index 0000000..23369e5 --- /dev/null +++ b/tests/TODO.rst @@ -0,0 +1,11 @@ +================= +TODO List: +================= + +Issues General +^^^^^^^^^^^^^^ +* Implement checkpoints for endpoints with available search, ordering +* Check: Are permissions work correct? +* Check: Are cached permissions work correct? +* Check: Pagination test cases + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9adb50c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +import pytest +from flask import url_for +from webtest import TestApp + +from task_office.app import create_app +from task_office.extensions.db import db as _db +from task_office.settings import app_config +from tests.factories import USER_FACTORY_DEFAULT_PASSWORD + + +@pytest.yield_fixture(scope="function") +def app(): + """An application for the tests.""" + _app = create_app(app_config) + + with _app.app_context(): + _db.create_all() + + ctx = _app.test_request_context() + ctx.push() + + yield _app + + ctx.pop() + + +@pytest.fixture(scope="function") +def testapp(app): + """A Webtest app.""" + return TestApp(app) + + +@pytest.yield_fixture(scope="function") +def db(app): + """A database for the tests.""" + _db.app = app + with app.app_context(): + _db.create_all() + + yield _db + + # Explicitly close DB connection + _db.session.close() + _db.drop_all() + + +@pytest.fixture(scope="function") +def auth_user(func_users, testapp): + sign_in_url = url_for("api_v1.sign_in") + user = func_users.get_single() + data = { + "email": user.email, + "password": USER_FACTORY_DEFAULT_PASSWORD, + } + resp = testapp.post_json(sign_in_url, data, status=200) + return {"current_user": user, "auth_data": resp.json} + + +pytest_plugins = [ + "tests.fixtures.model_fixtures", + "tests.unit.app.core.fixtures", + "tests.integration.app.auth.fixtures", + "tests.integration.app.boards.fixtures", + "tests.integration.app.columns.fixtures", + "tests.integration.app.tasks.fixtures", + "tests.integration.app.permissions.fixtures", +] diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..7b58a45 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,118 @@ +import datetime +import uuid + +import factory +from factory import PostGenerationMethodCall, Sequence +from factory.alchemy import SQLAlchemyModelFactory +from factory.fuzzy import FuzzyChoice, FuzzyDateTime +from pytz import UTC + +from task_office.core.models.db_models import User, Board, BoardColumn, Task, Permission +from task_office.extensions.db import db as _db + + +class BaseFactory(SQLAlchemyModelFactory): + """Base factory.""" + + uuid = Sequence(lambda n: uuid.uuid4().hex) + + class Meta: + """Factory configuration.""" + + abstract = True + sqlalchemy_session = _db.session + + +USER_FACTORY_DEFAULT_PASSWORD = "password" + + +class UserFactory(BaseFactory): + """User factory.""" + + username = Sequence(lambda n: "user{0}".format(n)) + email = Sequence(lambda n: "user{0}@example.com".format(n)) + password = PostGenerationMethodCall("set_password", USER_FACTORY_DEFAULT_PASSWORD) + + class Meta: + """Factory configuration.""" + + model = User + + +class BoardColumnFactory(BaseFactory): + """BoardColumn factory.""" + + name = Sequence(lambda n: "BoardColumn Name #{0}".format(n)) + position = Sequence(lambda n: n) + + @factory.post_generation + def tasks(self, create, extracted, **kwargs): + if create: + TaskFactory(column_uuid=self.uuid), + TaskFactory(column_uuid=self.uuid) + + class Meta: + """Factory configuration.""" + + model = BoardColumn + + +class TaskFactory(BaseFactory): + """Task factory.""" + + expire_at = FuzzyDateTime(datetime.datetime(2020, 1, 1, tzinfo=UTC)) + label = Sequence(lambda n: "Task Label #{0}".format(n)) + name = Sequence(lambda n: "Task Name #{0}".format(n)) + description = Sequence(lambda n: "Task Description #{0}".format(n)) + state = FuzzyChoice(choices=Task.State.get_values()) + position = Sequence(lambda n: n) + creator = factory.SubFactory(UserFactory) + + @factory.post_generation + def set_creator_uuid(self, create, extracted, **kwargs): + if create: + self.creator_uuid = self.creator.uuid + + class Meta: + """Factory configuration.""" + + model = Task + + +class PermissionFactory(BaseFactory): + """Permission factory.""" + + role = FuzzyChoice(choices=Permission.Role.get_values()) + + class Meta: + """Factory configuration.""" + + model = Permission + + +class BoardFactory(BaseFactory): + """Board factory.""" + + name = Sequence(lambda n: "Board Name #{0}".format(n)) + description = Sequence(lambda n: "Board Description #{0}".format(n)) + + @factory.post_generation + def columns(self, create, extracted, **kwargs): + if create: + BoardColumnFactory(board_uuid=self.uuid) + BoardColumnFactory(board_uuid=self.uuid) + BoardColumnFactory(board_uuid=self.uuid) + + @factory.post_generation + def perms(self, create, extracted, **kwargs): + if create: + PermissionFactory( + board_uuid=self.uuid, + user_uuid=UserFactory().uuid, + role=Permission.Role.EDITOR.value, + ) + + class Meta: + """Factory configuration.""" + + model = Board diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/model_fixtures.py b/tests/fixtures/model_fixtures.py new file mode 100644 index 0000000..26227a7 --- /dev/null +++ b/tests/fixtures/model_fixtures.py @@ -0,0 +1,67 @@ +import pytest + +from task_office.core.models.db_models import Permission +from tests.factories import UserFactory, BoardFactory, PermissionFactory + + +@pytest.fixture(scope="function") +def func_users(db): + """A users for the tests.""" + users = UserFactory.create_batch(3) + + class User: + @staticmethod + def get_single(): + return users[0] + + @staticmethod + def get_list(): + return users + + return User() + + +@pytest.fixture +def ses_boards(session_users): + """A boards for the tests.""" + user = session_users.get_single() + boards = BoardFactory.create_batch(3, owner_uuid=str(user.uuid)) + for item in boards: + PermissionFactory( + role=Permission.Role.EDITOR.value, user_uuid=user.uuid, board_uuid=item.uuid + ) + + class Board: + @staticmethod + def get_single(): + return boards[0] + + @staticmethod + def get_list(): + return boards + + return Board() + + +@pytest.fixture(scope="function") +def func_boards(func_users, db): + """A boards for the tests.""" + user = func_users.get_single() + boards = BoardFactory.create_batch(5, owner_uuid=str(user.uuid)) + boards = BoardFactory.create_batch(3, owner_uuid=str(user.uuid)) + for item in boards: + PermissionFactory( + role=Permission.Role.EDITOR.value, user_uuid=user.uuid, board_uuid=item.uuid + ) + db.session.commit() + + class Board: + @staticmethod + def get_single(): + return boards[0] + + @staticmethod + def get_list(): + return boards + + return Board() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/__init__.py b/tests/integration/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/auth/__init__.py b/tests/integration/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/auth/fixtures.py b/tests/integration/app/auth/fixtures.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/integration/app/auth/fixtures.py @@ -0,0 +1 @@ + diff --git a/tests/integration/app/auth/test_api_auth.py b/tests/integration/app/auth/test_api_auth.py new file mode 100644 index 0000000..3fdfb13 --- /dev/null +++ b/tests/integration/app/auth/test_api_auth.py @@ -0,0 +1,186 @@ +import uuid + +import pytest +from flask import url_for + +from task_office.core.utils import generate_str +from tests.factories import USER_FACTORY_DEFAULT_PASSWORD + +SIGN_UP_USERS_VALID_DATA = [ + { + "password_confirm": "user11", + "password": "user11", + "email": "user1@gmaill.com", + "username": "usr1", + }, + { + "password_confirm": "user5678910124556122345811111111", + "password": "user5678910124556122345811111111", + "email": "user2@gmaill.com", + "username": "usr2", + }, + { + "password_confirm": "user11", + "password": "user11", + "email": "user3@gmaill.com", + "username": "usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456", + }, +] + + +@pytest.mark.parametrize("data", SIGN_UP_USERS_VALID_DATA) +def test_sign_up_success(testapp, data): + url = url_for("api_v1.sign_up") + testapp.post_json(url, data, status=200) + + +SIGN_UP_USERS_INVALID_DATA = [ + # to short password + { + "password_confirm": "us", + "password": "us", + "email": "user1@gmaill.com", + "username": "usr1", + }, + # invalid email + { + "password_confirm": "user11", + "password": "user11", + "email": "user1gmaill.com", + "username": "usr1", + }, + # to short username + { + "password_confirm": "user11", + "password": "user11", + "email": "user1@gmaill.com", + "username": "us", + }, + # to long password + { + "password_confirm": "user56789101245561223458111111111", + "password": "user56789101245561223458111111111", + "email": "user2@gmaill.com", + "username": "usr2", + }, + # to long username + { + "password_confirm": "user11", + "password": "user11", + "email": "user3@gmaill.com", + "username": "usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr31234561", + }, + # passwords not equal + { + "password_confirm": "user12", + "password": "user11", + "email": "user3@gmaill.com", + "username": "usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456usr3123456", + }, +] + + +@pytest.mark.parametrize("data", SIGN_UP_USERS_INVALID_DATA) +def test_sign_up_failed(testapp, data): + url = url_for("api_v1.sign_up") + testapp.post_json(url, data, status=422) + + +def test_sign_up_already_exists_username(testapp, func_users): + available_username = func_users.get_single().username + + data = { + "password_confirm": "user11", + "password": "user11", + "email": "user1@gmaill.com", + "username": available_username, + } + + url = url_for("api_v1.sign_up") + testapp.post_json(url, data, status=422) + + +def test_sign_up_already_exists_email(testapp, func_users): + available_email = func_users.get_single().email + + data = { + "password_confirm": "user11", + "password": "user11", + "email": available_email, + "username": "user", + } + + url = url_for("api_v1.sign_up") + testapp.post_json(url, data, status=422) + + +@pytest.mark.parametrize("data", SIGN_UP_USERS_VALID_DATA) +def test_sign_up_in_success(testapp, data): + # sign up + sign_up_url = url_for("api_v1.sign_up") + testapp.post_json(sign_up_url, data, status=200) + + # sign in + sign_in_url = url_for("api_v1.sign_in") + data = { + "email": data["email"], + "password": data["password"], + } + testapp.post_json(sign_in_url, data, status=200) + + +def test_sign_in_data__with_valid_data(testapp, func_users): + sign_in_url = url_for("api_v1.sign_in") + data = { + "email": func_users.get_single().email, + "password": USER_FACTORY_DEFAULT_PASSWORD, + } + testapp.post_json(sign_in_url, data, status=200) + + +def test_sign_in_data_with_invalid_data(testapp): + sign_in_url = url_for("api_v1.sign_in") + data = { + "email": f"user{uuid.uuid4().hex}@gmail.com", + "password": "some_wrong_password", + } + testapp.post_json(sign_in_url, data, status=422) + + +def test_sign_in_data_with_invalid_email(testapp): + sign_in_url = url_for("api_v1.sign_in") + data = { + "email": "user{}@gmail.com".format(uuid.uuid4().hex), + "password": USER_FACTORY_DEFAULT_PASSWORD, + } + testapp.post_json(sign_in_url, data, status=422) + + +def test_sign_in_data__with_invalid_password(testapp, func_users): + sign_in_url = url_for("api_v1.sign_in") + data = { + "email": func_users.get_single().email, + "password": "some_wrong_password", + } + testapp.post_json(sign_in_url, data, status=422) + + +def test_refresh_success(testapp, auth_user): + url = url_for("api_v1.refresh",) + token = auth_user["auth_data"]["tokens"]["refresh"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.post_json(url, headers=headers, status=200) + + +REFRESH_INVALID = [ + "", + None, + generate_str(25), +] + + +@pytest.mark.parametrize("data", REFRESH_INVALID) +def test_refresh_failed(testapp, data): + url = url_for("api_v1.refresh",) + headers = {"Authorization": f"Bearer {data}"} + testapp.post_json(url, headers=headers, status=422) diff --git a/tests/integration/app/boards/__init__.py b/tests/integration/app/boards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/boards/fixtures.py b/tests/integration/app/boards/fixtures.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/integration/app/boards/fixtures.py @@ -0,0 +1 @@ +import pytest diff --git a/tests/integration/app/boards/test_api_boards.py b/tests/integration/app/boards/test_api_boards.py new file mode 100644 index 0000000..e756184 --- /dev/null +++ b/tests/integration/app/boards/test_api_boards.py @@ -0,0 +1,172 @@ +import uuid + +import pytest as pytest +from flask import url_for + +from task_office.core.utils import generate_str + + +def test_get_list_boards_without_auth(testapp): + url = url_for("api_v1.get_list_boards") + testapp.get(url, status=401) + + +def test_get_board_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_board", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex + ) + testapp.get(url, status=401) + + +def test_get_board_users_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_board_users", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_create_board_without_auth(testapp): + url = url_for("api_v1.create_board") + data = { + "name": "Board # name", + "description": "Board #3 description", + "is_active": True, + } + testapp.post_json(url, data, status=401) + + +def test_update_board_without_auth(testapp, func_boards): + url = url_for( + "api_v1.update_board", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + data = { + "name": "Board # name", + "description": "Board #3 description", + "is_active": True, + } + testapp.put_json(url, data, status=401) + + +def test_get_boards_list(testapp, auth_user): + url = url_for("api_v1.get_list_boards") + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +def test_get_board(testapp, func_boards, auth_user): + url = url_for( + "api_v1.get_board", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +def test_get_board_users(testapp, func_boards, auth_user): + url = url_for( + "api_v1.get_board_users", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +BOARDS_VALID_DATA = [ + # Typical board data + {"name": "Board 1# name", "description": "Board #3 description", "is_active": True}, + # Data without is_active + {"name": "Board 2# name", "description": "Board #3 description"}, + # Data without description + {"name": "Board 3# name", "is_active": True}, + # Data without description, is_active + {"name": "Board 4# name"}, + # Data with min length name + {"name": "B", "description": "Board #3 description", "is_active": True}, + # Data with max length name(80) + { + "name": generate_str(80), + "description": "Board #3 description", + "is_active": True, + }, + # Data with min length description + {"name": "Board 5# name", "description": "", "is_active": True}, + # Data with max length description(255) + {"name": "Board 5# name", "description": generate_str(255), "is_active": True}, +] + + +@pytest.mark.parametrize("data", BOARDS_VALID_DATA) +def test_create_board_success(testapp, auth_user, data): + url = url_for("api_v1.create_board") + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.post_json(url, data, headers=headers, status=200) + + +BOARDS_INVALID_DATA = [ + # Data with zero length name + {"name": "", "description": "Board #1 description", "is_active": True}, + # Data without name + {"description": "Board #2 description", "is_active": True}, + # Data without None name + {"name": None, "description": "Board #3 description", "is_active": True}, + # Data with exceeded length name + { + "name": generate_str(81), + "description": "Board #1 description", + "is_active": True, + }, + # Data with exceeded description + {"name": "Board 1# name", "description": generate_str(256), "is_active": True}, + # Data with None description + {"name": "Board 2# name", "description": None, "is_active": True}, + # Data with incorrect name type + {"name": True, "description": None, "is_active": True}, + # Data with incorrect description type + {"name": "Board 3# name", "description": 12345, "is_active": True}, + # Data with incorrect is_active type + {"name": "Board 4# name", "description": "Board #2 description", "is_active": 48}, +] + + +@pytest.mark.parametrize("data", BOARDS_INVALID_DATA) +def test_create_board_failed(testapp, auth_user, data): + url = url_for("api_v1.create_board") + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.post_json(url, data, headers=headers, status=422) + + +def test_update_board(testapp, func_boards, auth_user): + url = url_for( + "api_v1.update_board", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + + headers = {"Authorization": f"Bearer {token}"} + data = { + "name": "Board 1# updated name", + "description": "Board #3 updated description", + "is_active": True, + } + resp = testapp.put_json(url, data, headers=headers, status=200) + + assert resp.json["name"] == data["name"] + assert resp.json["description"] == data["description"] + assert resp.json["is_active"] == data["is_active"] + + +def test_update_board_not_exists(testapp, auth_user): + url = url_for("api_v1.update_board", board_uuid=uuid.uuid4().hex) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + + headers = {"Authorization": f"Bearer {token}"} + data = { + "name": "Board 1# updated name", + "description": "Board #3 updated description", + "is_active": True, + } + testapp.put_json(url, data, headers=headers, status=404) diff --git a/tests/integration/app/columns/__init__.py b/tests/integration/app/columns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/columns/fixtures.py b/tests/integration/app/columns/fixtures.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/integration/app/columns/fixtures.py @@ -0,0 +1 @@ +import pytest diff --git a/tests/integration/app/columns/test_api_columns.py b/tests/integration/app/columns/test_api_columns.py new file mode 100644 index 0000000..7b6d96d --- /dev/null +++ b/tests/integration/app/columns/test_api_columns.py @@ -0,0 +1,122 @@ +import uuid + +import pytest +from flask import url_for + +from task_office.core.utils import generate_str + + +def test_get_board_columns_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_list_columns", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_create_board_columns_without_auth(testapp, func_boards): + url = url_for( + "api_v1.create_column", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + data = { + "name": "Column # name", + "position": 1, + } + testapp.post_json(url, data, status=401) + + +def test_get_board_columns_metadata_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_columns_meta_data", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_get_board_columns_metadata_success(testapp, func_boards, auth_user): + url = url_for( + "api_v1.get_columns_meta_data", board_uuid=func_boards.get_single().uuid, + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +def test_get_board_columns_success(testapp, func_boards, auth_user): + url = url_for("api_v1.get_list_columns", board_uuid=func_boards.get_single().uuid,) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +COLUMNS_VALID_DATA = [ + # Typical board data + {"name": "BoardColumn 1# name", "position": 1}, + # With min length name + {"name": "B", "position": 1}, + # With max length name + {"name": generate_str(120), "position": 1}, +] + + +@pytest.mark.parametrize("data", COLUMNS_VALID_DATA) +def test_create_board_columns_success(testapp, auth_user, func_boards, data): + url = url_for("api_v1.create_column", board_uuid=func_boards.get_single().uuid,) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.post_json(url, data, headers=headers, status=200) + + +COLUMNS_INVALID_DATA = [ + # With empty name + {"name": "", "position": 1}, + # With None Name + {"name": None, "position": 1}, + # Without name + {"position": 1}, + # With exceeded name length + {"name": generate_str(121), "position": 1}, + # With to small position + {"name": "BoardColumn 1# name", "position": 0}, + # With None position + {"name": "BoardColumn 1# name", "position": None}, + # Without position + {"name": "BoardColumn 1# name"}, +] + + +@pytest.mark.parametrize("data", COLUMNS_INVALID_DATA) +def test_create_board_columns_failed(testapp, auth_user, func_boards, data): + url = url_for("api_v1.create_column", board_uuid=func_boards.get_single().uuid,) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.post_json(url, data, headers=headers, status=422) + + +def test_update_board_columns(testapp, func_boards, auth_user): + board = func_boards.get_single() + column = board.columns[0] + url = url_for( + "api_v1.update_column", board_uuid=board.uuid, column_uuid=column.uuid + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + + headers = {"Authorization": f"Bearer {token}"} + data = {"name": "BoardColumn 1# name", "position": 1} + resp = testapp.put_json(url, data, headers=headers, status=200) + + assert resp.json["name"] == data["name"] + assert resp.json["position"] == data["position"] + + +def test_update_board_columns_not_exists(testapp, func_boards, auth_user): + board_uuid = func_boards.get_single() + column_uuid = uuid.uuid4().hex + url = url_for( + "api_v1.update_column", board_uuid=board_uuid, column_uuid=column_uuid + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + + headers = {"Authorization": f"Bearer {token}"} + data = {"name": "BoardColumn 1# name", "position": 1} + testapp.put_json(url, data, headers=headers, status=404) diff --git a/tests/integration/app/permissions/__init__.py b/tests/integration/app/permissions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/permissions/fixtures.py b/tests/integration/app/permissions/fixtures.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/integration/app/permissions/fixtures.py @@ -0,0 +1 @@ +import pytest diff --git a/tests/integration/app/permissions/test_api_permissions.py b/tests/integration/app/permissions/test_api_permissions.py new file mode 100644 index 0000000..677bfda --- /dev/null +++ b/tests/integration/app/permissions/test_api_permissions.py @@ -0,0 +1,121 @@ +import uuid + +import pytest +from flask import url_for + +from task_office.core.models.db_models import Permission +from tests.factories import UserFactory + + +def test_get_board_permissions_list_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_list_permission", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_create_board_permissions_without_auth(testapp, func_boards, func_users): + board = func_boards.get_single() + url = url_for("api_v1.create_permission", board_uuid=board.uuid) + data = {"role": 2, "user_uuid": func_users.get_single().uuid} + testapp.post_json(url, data, status=401) + + +def test_update_board_permission_without_auth(testapp, func_boards): + board = func_boards.get_single() + permission = board.perms[0] + url = url_for( + "api_v1.update_permission", + board_uuid=board.uuid, + permission_uuid=permission.uuid, + ) + data = { + "role": 3, + } + testapp.put_json(url, data, status=401) + + +def test_get_board_permission_without_auth(testapp, func_boards): + board = func_boards.get_single() + permission = board.perms[0] + url = url_for( + "api_v1.get_permission_by_uuid", + board_uuid=board.uuid, + permission_uuid=permission.uuid, + ) + testapp.get(url, status=401) + + +def test_get_board_permissions_meta_without_auth(testapp, func_boards): + board = func_boards.get_single() + url = url_for("api_v1.get_permissions_meta_data", board_uuid=board.uuid,) + testapp.get(url, status=401) + + +def test_get_board_permissions_list(testapp, func_boards): + url = url_for( + "api_v1.get_list_permission", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_get_board_permission(testapp, func_boards, auth_user): + board = func_boards.get_single() + permission = board.perms[0] + url = url_for( + "api_v1.get_permission_by_uuid", + board_uuid=board.uuid, + permission_uuid=permission.uuid, + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + testapp.get(url, headers=headers, status=200) + + +def test_get_board_permissions_meta(testapp, func_boards, auth_user): + board = func_boards.get_single() + url = url_for("api_v1.get_permissions_meta_data", board_uuid=board.uuid,) + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + testapp.get(url, headers=headers, status=200) + + +PERMISSIONS_VALID_DATA = [{"role": role} for role in Permission.Role.get_values()] + + +@pytest.mark.parametrize("data", PERMISSIONS_VALID_DATA) +def test_create_board_permissions(testapp, func_boards, auth_user, data): + board = func_boards.get_single() + url = url_for("api_v1.create_permission", board_uuid=board.uuid) + data["user_uuid"] = UserFactory().uuid + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + testapp.post_json(url, data, headers=headers, status=200) + + +def test_update_board_permission(testapp, auth_user, func_boards): + permission = auth_user["current_user"].perms[0] + + url = url_for( + "api_v1.update_permission", + board_uuid=permission.board_uuid, + permission_uuid=permission.uuid, + ) + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + data = { + "role": Permission.Role.STAFF.value, + "user_uuid": auth_user["current_user"].uuid, + } + resp = testapp.put_json(url, data, headers=headers, status=200) + + assert resp.json["role"] == permission.role diff --git a/tests/integration/app/tasks/__init__.py b/tests/integration/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/app/tasks/fixtures.py b/tests/integration/app/tasks/fixtures.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/integration/app/tasks/fixtures.py @@ -0,0 +1 @@ +import pytest diff --git a/tests/integration/app/tasks/test_api_tasks.py b/tests/integration/app/tasks/test_api_tasks.py new file mode 100644 index 0000000..3628c32 --- /dev/null +++ b/tests/integration/app/tasks/test_api_tasks.py @@ -0,0 +1,316 @@ +import uuid + +import pytest +from flask import url_for + +from task_office.core.utils import generate_str + + +def test_get_tasks_without_auth(testapp, func_boards): + board_uuid = func_boards.get_single().uuid + url = url_for("api_v1.get_list_tasks", board_uuid=board_uuid,) + testapp.get(url, status=401) + + +def test_get_tasks_by_columns_without_auth(testapp, func_boards): + board_uuid = func_boards.get_single().uuid + url = url_for("api_v1.get_list_tasks_by_columns", board_uuid=board_uuid,) + testapp.get(url, status=401) + + +def test_create_task_without_auth(testapp, func_boards): + url = url_for( + "api_v1.create_task", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + data = { + "label": "Label#", + "expire_at": "2020-05-25 05:30:11", + "name": "Task # name", + "description": "Task # description", + "position": 1, + "state": 1, + "column_uuid": func_boards.get_single().columns[0].uuid, + } + testapp.post_json(url, data, status=401) + + +def test_update_task_without_auth(testapp, func_boards): + task_uuid = func_boards.get_single().columns[0].tasks[0].uuid + board_uuid = func_boards.get_single().uuid + url = url_for("api_v1.update_task", board_uuid=board_uuid, task_uuid=task_uuid) + data = { + "label": "Label#", + "expire_at": "2020-05-25 05:30:11", + "name": "Task # name", + "description": "Task # description", + "position": 1, + "state": 1, + "column_uuid": func_boards.get_single().columns[0].uuid, + } + testapp.put_json(url, data, status=401) + + +def test_get_tasks_metadata_without_auth(testapp, func_boards): + url = url_for( + "api_v1.get_tasks_meta_data", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + testapp.get(url, status=401) + + +def test_get_tasks_metadata_success(testapp, func_boards, auth_user): + url = url_for( + "api_v1.get_tasks_meta_data", + board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +def test_get_tasks(testapp, func_boards, auth_user): + board_uuid = func_boards.get_single().uuid + url = url_for("api_v1.get_list_tasks", board_uuid=board_uuid,) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +def test_get_tasks_by_columns(testapp, func_boards, auth_user): + board_uuid = func_boards.get_single().uuid + url = url_for("api_v1.get_list_tasks_by_columns", board_uuid=board_uuid,) + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + testapp.get(url, headers=headers, status=200) + + +TASKS_VALID_DATA = [ + # Typical task data + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #1 name", + "description": "Task #1 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with max label name + { + "label": generate_str(size=80), + "expire_at": "2020-05-25 05:30:11", + "name": "Task #2 name", + "description": "Task #2 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task without label + { + "expire_at": "2020-05-25 05:30:11", + "name": "Task #3 name", + "description": "Task #3 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task without expire_at + { + "label": "Label #1", + "name": "Task #4 name", + "description": "Task #4 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with min name length + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "T", + "description": "Task #5 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with max name length + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #6 name", + "description": "Task #6 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with max description length + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #7 name", + "description": generate_str(120), + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task without state + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #8 name", + "description": "Task #8 description", + "position": 1, + "column_uuid": None, + "performers": [], + }, + # Task without position + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #9 name", + "description": "Task #9 description", + "state": 1, + "column_uuid": None, + "performers": [], + }, +] + + +@pytest.mark.parametrize("data", TASKS_VALID_DATA) +def create_tasks_success(testapp, func_boards, auth_user, data): + url = url_for( + "api_v1.create_task", board_uuid=uuid.UUID(func_boards.get_single().uuid).hex, + ) + + data["performers"].apend(auth_user["auth_data"]["uuid"]) + data["column_uuid"] = func_boards.get_single().columns[0].uuid + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + testapp.post_json(url, data, headers=headers, status=200) + + +TASKS_INVALID_DATA = [ + # Task with None expire_at + { + "label": "Label #1", + "expire_at": None, + "name": "Task #1 name", + "description": "Task #1 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with None label + { + "label": None, + "expire_at": "2020-05-25 05:30:11", + "name": "Task #1 name", + "description": "Task #1 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with empty name + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "", + "description": "Task #1 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with exceeded name length + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": generate_str(120), + "description": "Task #1 description", + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with exceeded description length + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #1 name", + "description": generate_str(121), + "position": 1, + "state": 1, + "column_uuid": None, + "performers": [], + }, + # Task with None state + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #1 name", + "description": "Task #1 description", + "position": 1, + "state": None, + "column_uuid": None, + "performers": [], + }, + # Task with None position + { + "label": "Label #1", + "expire_at": "2020-05-25 05:30:11", + "name": "Task #1 name", + "description": "Task #1 description", + "position": None, + "state": 1, + "column_uuid": None, + "performers": [], + }, +] + + +@pytest.mark.parametrize("data", TASKS_VALID_DATA) +def create_tasks_failed(testapp, func_boards, auth_user, data): + url = url_for("api_v1.create_task", board_uuid=func_boards.get_single().uuid,) + + data["performers"].apend(auth_user["auth_data"]["uuid"]) + data["column_uuid"] = func_boards.get_single().columns[0].uuid + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + testapp.post_json(url, data, headers=headers, status=422) + + +def update_tasks_success(testapp, func_boards, auth_user): + board = func_boards.get_single() + + token = auth_user["auth_data"]["tokens"]["access"]["token"] + headers = {"Authorization": f"Bearer {token}"} + + item_number = 100 + for task in board.columns[0].tasks: + url = url_for("api_v1.update_task", board_uuid=board.uuid, task_uuid=task.uuid) + data = ( + { + "label": f"Label #{item_number}", + "expire_at": "2020-05-25 05:30:11", + "name": f"Task #{item_number} name", + "description": f"Task #{item_number} description", + }, + ) + resp = testapp.put_json(url, data, headers=headers, status=200) + resp_data = resp.json + assert data["label"] == resp_data["label"] + assert data["name"] == resp_data["name"] + assert data["description"] == resp_data["description"] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/__init__.py b/tests/unit/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/auth/__init__.py b/tests/unit/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/auth/test_utils.py b/tests/unit/app/auth/test_utils.py new file mode 100644 index 0000000..f8d3907 --- /dev/null +++ b/tests/unit/app/auth/test_utils.py @@ -0,0 +1,3 @@ +# TODO(Medniy2000) Implement auth utils test cases: +# _get_cached_permissions reset_permissions, +# permission, reset_permissions_for_board_staff diff --git a/tests/unit/app/columns/__init__.py b/tests/unit/app/columns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/columns/test_utils.py b/tests/unit/app/columns/test_utils.py new file mode 100644 index 0000000..3ef660f --- /dev/null +++ b/tests/unit/app/columns/test_utils.py @@ -0,0 +1,2 @@ +# TODO(Medniy2000) Implement columns utils test cases: +# reset_columns_ordering diff --git a/tests/unit/app/core/__init__.py b/tests/unit/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/core/fixtures.py b/tests/unit/app/core/fixtures.py new file mode 100644 index 0000000..bd1c7c8 --- /dev/null +++ b/tests/unit/app/core/fixtures.py @@ -0,0 +1,29 @@ +import pytest + +VALID_UUID_LIST = [ + "31b2e088-d37b-4e90-9983-1ef6f44b932a", + "31b2e088d37b4e9099831ef6f44b932a", +] + +INVALID_UUID_LIST = [ + "31b2e088-d37b-4e90-9983-1ef6f44b932z", + "12345678910", + "ZXsdpk12-21-zxczxcl-58484848-zx-xcxc", +] + +INVALID_BOARDS_QUERY_DATA = [{"uuid": item} for item in VALID_UUID_LIST] + + +@pytest.fixture(params=VALID_UUID_LIST) +def valid_uuid_list(request): + return request.param + + +@pytest.fixture(params=INVALID_UUID_LIST) +def invalid_uuid_list(request): + return request.param + + +@pytest.fixture(params=INVALID_BOARDS_QUERY_DATA) +def invalid_boards_query_list(request, db): + return request.param diff --git a/tests/unit/app/core/test_utils.py b/tests/unit/app/core/test_utils.py new file mode 100644 index 0000000..9b73283 --- /dev/null +++ b/tests/unit/app/core/test_utils.py @@ -0,0 +1,63 @@ +import uuid + +import pytest +from flask import request, url_for + +from task_office.core.models.db_models import Board +from task_office.core.utils import ( + is_uuid, + non_empty_query_required, + empty_query_required, + validate_request_url_uuid, +) +from task_office.exceptions import InvalidUsage + + +def test_is_uuid_success(valid_uuid_list): + assert is_uuid(valid_uuid_list) is True + + +def test_is_uuid_failed(invalid_uuid_list): + assert is_uuid(invalid_uuid_list) is False + + +def test_non_empty_query_required_success(func_boards): + non_empty_query_required(Board, name=func_boards.get_single().name) + + +def test_non_empty_query_required_failed(invalid_boards_query_list): + with pytest.raises(InvalidUsage): + non_empty_query_required(Board, **invalid_boards_query_list) + + +def test_empty_query_required_success(invalid_boards_query_list): + empty_query_required(Board, **invalid_boards_query_list) + + +def test_empty_query_required_failed(func_boards): + with pytest.raises(InvalidUsage): + empty_query_required(Board, name=func_boards.get_single().name) + + +def test_request_url_uuid_success( + func_boards, testapp, +): + for item in func_boards.get_list(): + uuid_hexed = uuid.UUID(str(item.uuid)).hex + request.url = url_for("api_v1.get_board", board_uuid=uuid_hexed,) + validate_request_url_uuid(Board, "uuid", uuid_hexed, True) + + +def test_request_url_uuid_failed_case1(invalid_uuid_list, testapp): + with pytest.raises(InvalidUsage): + uuid_hexed = invalid_uuid_list + request.url = url_for("api_v1.get_board", board_uuid=uuid_hexed,) + validate_request_url_uuid(Board, "uuid", uuid_hexed, False) + + +def test_request_url_uuid_failed_case2(testapp): + for item in range(2): + with pytest.raises(InvalidUsage): + uuid_hexed = uuid.uuid4().hex + request.url = url_for("api_v1.get_board", board_uuid=uuid_hexed,) + validate_request_url_uuid(Board, "uuid", uuid_hexed, True) diff --git a/translations/ru/LC_MESSAGES/messages.po b/translations/ru/LC_MESSAGES/messages.po new file mode 100644 index 0000000..00e4f4d --- /dev/null +++ b/translations/ru/LC_MESSAGES/messages.po @@ -0,0 +1,149 @@ +# Russian translations for PROJECT. +# Copyright (C) 2020 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2020. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2020-04-06 08:46+0300\n" +"PO-Revision-Date: 2020-01-04 15:13+0200\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.8.0\n" + +#: task_office/auth/jwt_error_handlers.py:44 +msgid "Token has been revoked" +msgstr "Токен был отменен" + +#: task_office/auth/jwt_error_handlers.py:48 +msgid "Fresh token required" +msgstr "Фреш токен необходим" + +#: task_office/auth/jwt_error_handlers.py:57 +msgid "Error loading the user {}" +msgstr "Ошибка загрузки пользователя" + +#: task_office/auth/jwt_error_handlers.py:63 +msgid "User claims verification failed" +msgstr "Верификация данных пользователя провалена" + +#: task_office/auth/schemas.py:25 +msgid "Passwords do not match" +msgstr "Пароли не совпадают" + +#: task_office/auth/schemas.py:55 +msgid "User not found" +msgstr "Пользователь не найден" + +#: task_office/auth/utils.py:57 task_office/permissions/views.py:69 +#: task_office/permissions/views.py:99 +msgid "Not allowed" +msgstr "Не разрешено" + +#: task_office/columns/views.py:60 task_office/tasks/views.py:79 +msgid "Must be between {} and {}" +msgstr "Должен быть между {} и {}" + +#: task_office/columns/views.py:71 task_office/columns/views.py:121 +#: task_office/core/validators.py:10 task_office/tasks/views.py:93 +#: task_office/tasks/views.py:163 +msgid "Already exists with value {}" +msgstr "Уже сужествует из значением {}" + +#: task_office/columns/views.py:108 task_office/tasks/views.py:142 +msgid "Must be between {} and {}." +msgstr "Должен быть между {} и {}." + +#: task_office/core/enums.py:43 +msgid "Ascend" +msgstr "Больше" + +#: task_office/core/enums.py:44 +msgid "Descend" +msgstr "Меньше" + +#: task_office/core/utils.py:23 task_office/core/utils.py:53 +#: task_office/core/utils.py:57 task_office/permissions/views.py:36 +#: task_office/permissions/views.py:58 task_office/permissions/views.py:92 +#: task_office/permissions/views.py:124 task_office/permissions/views.py:147 +msgid "Not found" +msgstr "Не найдено" + +#: task_office/core/utils.py:31 +msgid "Already exists" +msgstr "Уже существует" + +#: task_office/core/validators.py:37 +msgid "Not found with value {}" +msgstr "Не найдено из ззначением {}" + +#: task_office/core/helpers/listed_response.py:10 +msgid "Max value {} exceeded" +msgstr "Максимальное значение {} превышен" + +#: task_office/core/models/db_models.py:76 +msgid "Owner" +msgstr "Владелец" + +#: task_office/core/models/db_models.py:76 +msgid "Owner of board(creator)" +msgstr "Владелеу доски(создатель)" + +#: task_office/core/models/db_models.py:77 +msgid "Editor" +msgstr "Редактор" + +#: task_office/core/models/db_models.py:77 +msgid "Editor of board" +msgstr "Редактор доски" + +#: task_office/core/models/db_models.py:78 +msgid "Staff" +msgstr "Персонал" + +#: task_office/core/models/db_models.py:78 +msgid "Ordinary user" +msgstr "Обычный пользователь" + +#: task_office/core/models/db_models.py:130 +msgid "New" +msgstr "Новый" + +#: task_office/core/models/db_models.py:131 +msgid "In process" +msgstr "В процессе" + +#: task_office/core/models/db_models.py:132 +msgid "Rejected" +msgstr "Отклонено" + +#: task_office/core/models/db_models.py:133 +msgid "Done" +msgstr "Сделано" + +#: task_office/tasks/schemas/basic_schemas.py:130 +#: task_office/tasks/schemas/basic_schemas.py:132 +#: task_office/tasks/schemas/basic_schemas.py:180 +msgid "ascending" +msgstr "Увеличение" + +#: task_office/tasks/schemas/basic_schemas.py:131 +#: task_office/tasks/schemas/basic_schemas.py:133 +#: task_office/tasks/schemas/basic_schemas.py:181 +msgid "descending" +msgstr "Уменьшение" + +#~ msgid "Должен быть между {} и {}." +#~ msgstr "" + +#~ msgid "Max limit {} exceeded" +#~ msgstr "Максимальный лимит {} превышен" + diff --git a/translations/uk/LC_MESSAGES/messages.po b/translations/uk/LC_MESSAGES/messages.po new file mode 100644 index 0000000..81aa138 --- /dev/null +++ b/translations/uk/LC_MESSAGES/messages.po @@ -0,0 +1,146 @@ +# Ukrainian translations for PROJECT. +# Copyright (C) 2020 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2020. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2020-04-06 08:46+0300\n" +"PO-Revision-Date: 2020-01-04 15:13+0200\n" +"Last-Translator: FULL NAME \n" +"Language: uk\n" +"Language-Team: uk \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.8.0\n" + +#: task_office/auth/jwt_error_handlers.py:44 +msgid "Token has been revoked" +msgstr "Токен був відхилений" + +#: task_office/auth/jwt_error_handlers.py:48 +msgid "Fresh token required" +msgstr "Фреш токен необхідно" + +#: task_office/auth/jwt_error_handlers.py:57 +msgid "Error loading the user {}" +msgstr "Помилка завантаження користувача {}" + +#: task_office/auth/jwt_error_handlers.py:63 +msgid "User claims verification failed" +msgstr "Верифікація даних користувача провалена" + +#: task_office/auth/schemas.py:25 +msgid "Passwords do not match" +msgstr "Паролі не збігються" + +#: task_office/auth/schemas.py:55 +msgid "User not found" +msgstr "Користувач не знайдений" + +#: task_office/auth/utils.py:57 task_office/permissions/views.py:69 +#: task_office/permissions/views.py:99 +msgid "Not allowed" +msgstr "Не дозволено" + +#: task_office/columns/views.py:60 task_office/tasks/views.py:79 +msgid "Must be between {} and {}" +msgstr "Повинний бути між {} і {}" + +#: task_office/columns/views.py:71 task_office/columns/views.py:121 +#: task_office/core/validators.py:10 task_office/tasks/views.py:93 +#: task_office/tasks/views.py:163 +msgid "Already exists with value {}" +msgstr "Вже існує із значенням {}" + +#: task_office/columns/views.py:108 task_office/tasks/views.py:142 +msgid "Must be between {} and {}." +msgstr "Повинний бути між {} і {}." + +#: task_office/core/enums.py:43 +msgid "Ascend" +msgstr "Збільшення" + +#: task_office/core/enums.py:44 +msgid "Descend" +msgstr "Зменшення" + +#: task_office/core/utils.py:23 task_office/core/utils.py:53 +#: task_office/core/utils.py:57 task_office/permissions/views.py:36 +#: task_office/permissions/views.py:58 task_office/permissions/views.py:92 +#: task_office/permissions/views.py:124 task_office/permissions/views.py:147 +msgid "Not found" +msgstr "Не знайдено" + +#: task_office/core/utils.py:31 +msgid "Already exists" +msgstr "Вже існує" + +#: task_office/core/validators.py:37 +msgid "Not found with value {}" +msgstr "Не знайдено із значенням {}" + +#: task_office/core/helpers/listed_response.py:10 +msgid "Max value {} exceeded" +msgstr "Максимальне значення {} перевищено" + +#: task_office/core/models/db_models.py:76 +msgid "Owner" +msgstr "Власник" + +#: task_office/core/models/db_models.py:76 +msgid "Owner of board(creator)" +msgstr "Власник дошки(творець)" + +#: task_office/core/models/db_models.py:77 +msgid "Editor" +msgstr "Редактор" + +#: task_office/core/models/db_models.py:77 +msgid "Editor of board" +msgstr "Редактор дошки" + +#: task_office/core/models/db_models.py:78 +msgid "Staff" +msgstr "Персонал" + +#: task_office/core/models/db_models.py:78 +msgid "Ordinary user" +msgstr "Звичайний користувач" + +#: task_office/core/models/db_models.py:130 +msgid "New" +msgstr "Новий" + +#: task_office/core/models/db_models.py:131 +msgid "In process" +msgstr "В процесі" + +#: task_office/core/models/db_models.py:132 +msgid "Rejected" +msgstr "Відхилено" + +#: task_office/core/models/db_models.py:133 +msgid "Done" +msgstr "Завершено" + +#: task_office/tasks/schemas/basic_schemas.py:130 +#: task_office/tasks/schemas/basic_schemas.py:132 +#: task_office/tasks/schemas/basic_schemas.py:180 +msgid "ascending" +msgstr "Збільшення" + +#: task_office/tasks/schemas/basic_schemas.py:131 +#: task_office/tasks/schemas/basic_schemas.py:133 +#: task_office/tasks/schemas/basic_schemas.py:181 +msgid "descending" +msgstr "Зменшення" + +#~ msgid "Max limit {} exceeded" +#~ msgstr "Максимальне обмеження {} перевищено" + diff --git a/wsgi.py b/wsgi.py new file mode 100755 index 0000000..3703f31 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +"""Create an application instance.""" + +from task_office.app import create_app +from task_office.settings import app_config + +app = create_app(app_config)