From 932c6d36beee8385e73989ae61793836984bb265 Mon Sep 17 00:00:00 2001 From: through-your-tears Date: Sat, 6 Apr 2024 06:13:49 +0300 Subject: [PATCH] initial commit --- docker-compose.yml | 31 ++++ src/Dockerfile | 69 +++++++ src/conf/__init__.py | 0 src/conf/asgi.py | 16 ++ src/conf/exceptions.py | 28 +++ src/conf/settings.py | 170 ++++++++++++++++++ src/conf/urls.py | 35 ++++ src/conf/wsgi.py | 16 ++ src/core/__init__.py | 0 src/core/errors.py | 7 + src/core/fields.py | 19 ++ src/core/openapi.py | 24 +++ src/core/permissions.py | 10 ++ src/core/repositories.py | 31 ++++ src/entrypoint.prod.sh | 14 ++ src/jwtauth/__init__.py | 0 src/jwtauth/admin.py | 42 +++++ src/jwtauth/apps.py | 6 + src/jwtauth/backends.py | 48 +++++ src/jwtauth/forms.py | 50 ++++++ src/jwtauth/managers.py | 28 +++ src/jwtauth/migrations/__init__.py | 0 src/jwtauth/models.py | 65 +++++++ src/jwtauth/renderers.py | 24 +++ src/jwtauth/repositories.py | 39 ++++ src/jwtauth/serializers.py | 118 ++++++++++++ src/jwtauth/tests.py | 3 + src/jwtauth/token_generators.py | 23 +++ src/jwtauth/urls.py | 16 ++ src/jwtauth/views.py | 217 +++++++++++++++++++++++ src/manage.py | 22 +++ src/organizations/__init__.py | 0 src/organizations/admin.py | 3 + src/organizations/apps.py | 6 + src/organizations/migrations/__init__.py | 0 src/organizations/models.py | 34 ++++ src/organizations/repositories.py | 30 ++++ src/organizations/serializers.py | 41 +++++ src/organizations/tests.py | 3 + src/organizations/urls.py | 13 ++ src/organizations/views.py | 79 +++++++++ src/requirements.txt | Bin 0 -> 684 bytes 42 files changed, 1380 insertions(+) create mode 100644 docker-compose.yml create mode 100644 src/Dockerfile create mode 100644 src/conf/__init__.py create mode 100644 src/conf/asgi.py create mode 100644 src/conf/exceptions.py create mode 100644 src/conf/settings.py create mode 100644 src/conf/urls.py create mode 100644 src/conf/wsgi.py create mode 100644 src/core/__init__.py create mode 100644 src/core/errors.py create mode 100644 src/core/fields.py create mode 100644 src/core/openapi.py create mode 100644 src/core/permissions.py create mode 100644 src/core/repositories.py create mode 100644 src/entrypoint.prod.sh create mode 100644 src/jwtauth/__init__.py create mode 100644 src/jwtauth/admin.py create mode 100644 src/jwtauth/apps.py create mode 100644 src/jwtauth/backends.py create mode 100644 src/jwtauth/forms.py create mode 100644 src/jwtauth/managers.py create mode 100644 src/jwtauth/migrations/__init__.py create mode 100644 src/jwtauth/models.py create mode 100644 src/jwtauth/renderers.py create mode 100644 src/jwtauth/repositories.py create mode 100644 src/jwtauth/serializers.py create mode 100644 src/jwtauth/tests.py create mode 100644 src/jwtauth/token_generators.py create mode 100644 src/jwtauth/urls.py create mode 100644 src/jwtauth/views.py create mode 100644 src/manage.py create mode 100644 src/organizations/__init__.py create mode 100644 src/organizations/admin.py create mode 100644 src/organizations/apps.py create mode 100644 src/organizations/migrations/__init__.py create mode 100644 src/organizations/models.py create mode 100644 src/organizations/repositories.py create mode 100644 src/organizations/serializers.py create mode 100644 src/organizations/tests.py create mode 100644 src/organizations/urls.py create mode 100644 src/organizations/views.py create mode 100644 src/requirements.txt diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d61ee05 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: '3.3' + +services: + web: + restart: on-failure + build: + context: ./src + dockerfile: Dockerfile + command: gunicorn conf.wsgi:application --bind 0.0.0.0:8000 + volumes: + - static_volume:/home/app/web/static + - media_volume:/home/app/web/media + ports: + - 8000:8000 + depends_on: + - db + db: + restart: always + image: kartoza/postgis:13.0 + environment: + - POSTGRES_USER=my_user + - POSTGRES_PASSWORD=mysecretpassword + - POSTGRES_DB=db + ports: + - 5432:5432 + redis: + image: redis:5-alpine +volumes: + postgres_data: + static_volume: + media_volume: diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..5110c33 --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,69 @@ +########### +# BUILDER # +########### + +# pull official base image +FROM python:3.10-slim as builder + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN apt-get update -y \ + && apt-get install gcc python3-dev musl-dev binutils libproj-dev gdal-bin g++ -y + +# lint +RUN pip install --upgrade pip +COPY . . + +# install dependencies +COPY ./requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt + + +######### +# FINAL # +######### + +# pull official base image +FROM python:3.10-slim + +# create directory for the app user +RUN mkdir -p /home/app + +# create the app user +RUN addgroup --system app && adduser --system app --ingroup app + +# create the appropriate directories +ENV HOME=/home/app +ENV APP_HOME=/home/app/web +RUN mkdir $APP_HOME +RUN mkdir $APP_HOME/static +RUN mkdir $APP_HOME/media +WORKDIR $APP_HOME + +# install dependencies +RUN apt-get update -y && apt-get install gcc python3-dev musl-dev binutils libproj-dev gdal-bin g++ libpq-dev -y +COPY --from=builder /usr/src/app/wheels /wheels +COPY --from=builder /usr/src/app/requirements.txt . +RUN pip install --no-cache /wheels/* + +# copy entrypoint.prod.sh +COPY ./entrypoint.prod.sh . +RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.prod.sh +RUN chmod +x $APP_HOME/entrypoint.prod.sh + +# copy project +COPY . $APP_HOME + +# chown all the files to the app user +RUN chown -R app:app $APP_HOME + +# change to the app user +USER app + +# run entrypoint.prod.sh +ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] \ No newline at end of file diff --git a/src/conf/__init__.py b/src/conf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/conf/asgi.py b/src/conf/asgi.py new file mode 100644 index 0000000..6fab2f4 --- /dev/null +++ b/src/conf/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'conf.settings') + +application = get_asgi_application() diff --git a/src/conf/exceptions.py b/src/conf/exceptions.py new file mode 100644 index 0000000..9c1c45d --- /dev/null +++ b/src/conf/exceptions.py @@ -0,0 +1,28 @@ +from rest_framework.views import exception_handler + + +def core_exception_handler(exc, context): + response = exception_handler(exc, context) + handlers = { + 'ValidationError': _handle_generic_error + } + exception_class = exc.__class__.__name__ + + if exception_class in handlers: + return handlers[exception_class](exc, context, response) + + if response: + if response.data.get('detail') == 'Токен испорчен' or response.data.get('detail') == ( + 'Пользователь соответствующий данному токену не найден.') or response.data.get('detail') == ( + 'Учетные данные не были предоставлены.' + ): + response.status_code = 401 + + return response + + +def _handle_generic_error(exc, context, response): + response.data = { + 'errors': response.data + } + return response \ No newline at end of file diff --git a/src/conf/settings.py b/src/conf/settings.py new file mode 100644 index 0000000..b10cb30 --- /dev/null +++ b/src/conf/settings.py @@ -0,0 +1,170 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 5.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +import dj_database_url + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-*48y!hsr!tup+5z2r64o6c6mw%axkh@#bx62pes8=r7@_+7qtv' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'jwtauth', + 'organizations', + 'jwt', + 'corsheaders', + 'rest_framework', + 'drf_yasg', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_CREDENTIALS = True + +ROOT_URLCONF = 'conf.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'conf.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.contrib.gis.db.backends.postgis', + 'NAME': 'db', + 'USER': 'my_user', + 'PASSWORD': 'mysecretpassword', + 'HOST': 'db', + 'PORT': '5432' + } +} + +AUTH_USER_MODEL = 'jwtauth.CustomUser' + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'Europe/Moscow' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'static/' + +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'EXCEPTION_HANDLER': 'conf.exceptions.core_exception_handler', +# 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 30, + 'NON_FIELD_ERRORS_KEY': 'error', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'jwtauth.backends.JWTAuthentication', + ), +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://redis:6379/0", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" + +CACHE_TTL = 60 * 1 diff --git a/src/conf/urls.py b/src/conf/urls.py new file mode 100644 index 0000000..6bd1780 --- /dev/null +++ b/src/conf/urls.py @@ -0,0 +1,35 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include, re_path + +from core.openapi import schema_view + +api_urls = [ + path('organizations/', include('organizations.urls')), + path('user/', include('jwtauth.urls')) +] + +urlpatterns = [ + re_path(r'^docs/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path('admin/', admin.site.urls), + path('api/', include(api_urls)) +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/src/conf/wsgi.py b/src/conf/wsgi.py new file mode 100644 index 0000000..15200d6 --- /dev/null +++ b/src/conf/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'conf.settings') + +application = get_wsgi_application() diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/errors.py b/src/core/errors.py new file mode 100644 index 0000000..cfd7264 --- /dev/null +++ b/src/core/errors.py @@ -0,0 +1,7 @@ +from rest_framework.exceptions import APIException + + +class CodeExpired(APIException): + status_code = 400 + default_detail = 'Email code has been expired' + default_code = 'code_expired' \ No newline at end of file diff --git a/src/core/fields.py b/src/core/fields.py new file mode 100644 index 0000000..3c8e6d7 --- /dev/null +++ b/src/core/fields.py @@ -0,0 +1,19 @@ +from django.contrib.gis.geos.point import Point +from rest_framework import serializers + + +class LocationField(serializers.Field): + default_error_messages = { + "invalid": "Value must be valid list with lang and lat values." + } + + def to_representation(self, value): + return value.coords + + def to_internal_value(self, data): + try: + point = Point((data[0], data[1])) + except Exception: + self.fail("invalid") + + return point \ No newline at end of file diff --git a/src/core/openapi.py b/src/core/openapi.py new file mode 100644 index 0000000..1915bf9 --- /dev/null +++ b/src/core/openapi.py @@ -0,0 +1,24 @@ +from drf_yasg import openapi +from drf_yasg.generators import OpenAPISchemaGenerator +from drf_yasg.views import get_schema_view +from rest_framework import permissions + + +class BothHttpAndHttpsSchemaGenerator(OpenAPISchemaGenerator): + def get_schema(self, request=None, public=False): + schema = super().get_schema(request, public) + schema.schemes = ["http", "https"] + return schema + + +schema_view = get_schema_view( + openapi.Info( + title="", + default_version='v1', + description=" API", + license=openapi.License(name="BSD License"), + ), + public=True, + generator_class=BothHttpAndHttpsSchemaGenerator, + permission_classes=[permissions.AllowAny], +) \ No newline at end of file diff --git a/src/core/permissions.py b/src/core/permissions.py new file mode 100644 index 0000000..fd60ef5 --- /dev/null +++ b/src/core/permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsAuthorOrReadOnly(BasePermission): + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return obj.user == request.user \ No newline at end of file diff --git a/src/core/repositories.py b/src/core/repositories.py new file mode 100644 index 0000000..ea1c219 --- /dev/null +++ b/src/core/repositories.py @@ -0,0 +1,31 @@ +from django.core.exceptions import ObjectDoesNotExist + + +class BaseRepository: + model = None + + @classmethod + def get(cls, pk): + try: + return cls.model.objects.get(pk=pk) + except ObjectDoesNotExist: + return None + + @classmethod + def create(cls, **kwargs): + return cls.model.objects.create(**kwargs) + + @classmethod + def update(cls, instance, **kwargs): + for key, value in kwargs.items(): + setattr(instance, key, value) + instance.save() + return instance + + @classmethod + def delete(cls, instance): + instance.delete() + + @classmethod + def all(cls): + return cls.model.objects.all() diff --git a/src/entrypoint.prod.sh b/src/entrypoint.prod.sh new file mode 100644 index 0000000..1b31120 --- /dev/null +++ b/src/entrypoint.prod.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +if [ "$DATABASE" = "postgres" ] +then + echo "Waiting for postgres..." + + while ! nc -z $SQL_HOST $SQL_PORT; do + sleep 0.1 + done + + echo "PostgreSQL started" +fi + +exec "$@" \ No newline at end of file diff --git a/src/jwtauth/__init__.py b/src/jwtauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jwtauth/admin.py b/src/jwtauth/admin.py new file mode 100644 index 0000000..342523a --- /dev/null +++ b/src/jwtauth/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.contrib.auth.models import Group +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .forms import UserCreationForm, UserChangeForm + +from .models import CustomUser, Profile, RefreshToken + + +class UserAdmin(BaseUserAdmin): + # The forms to add and change user instances + form = UserChangeForm + add_form = UserCreationForm + + # The fields to be used in displaying the User model. + # These override the definitions on the base UserAdmin + # that reference specific fields on custom_auth.User. + list_display = ('email', 'is_staff') + list_filter = ('is_staff',) + fieldsets = ( + (None, {'fields': ('email', 'password', 'is_staff')}), + ) + # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin + # overrides get_fieldsets to use this attribute when creating a user. + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2', 'is_staff', 'is_superuser'), + }), + ) + search_fields = ('email', 'is_staff') + ordering = ('email',) + filter_horizontal = () + + +# Now register the new UserAdmin... +admin.site.register(CustomUser, UserAdmin) +# ... and, since we're not using Django's built-in permissions, +# unregister the Group model from admin. +admin.site.unregister(Group) + +admin.site.register(RefreshToken) +admin.site.register(Profile) \ No newline at end of file diff --git a/src/jwtauth/apps.py b/src/jwtauth/apps.py new file mode 100644 index 0000000..3c055f3 --- /dev/null +++ b/src/jwtauth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'jwtauth' diff --git a/src/jwtauth/backends.py b/src/jwtauth/backends.py new file mode 100644 index 0000000..357c00b --- /dev/null +++ b/src/jwtauth/backends.py @@ -0,0 +1,48 @@ +import datetime + +from django.conf import settings +import jwt +from rest_framework import authentication, exceptions + +from .models import CustomUser + + +class JWTAuthentication(authentication.BaseAuthentication): + authentication_header_prefix = 'Token' + + def authenticate(self, request): + request.user = None + + auth_header = authentication.get_authorization_header(request).split() + auth_header_prefix = self.authentication_header_prefix.lower() + + if not auth_header: + return + + if len(auth_header) == 1: + return + + elif len(auth_header) > 2: + return + + prefix = auth_header[0].decode('utf-8') + token = auth_header[1].decode('utf-8') + + if prefix.lower() != auth_header_prefix: + return + + return self.authenticate_credentials(token) + + def authenticate_credentials(self, token): + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except Exception: + return None + + try: + user = CustomUser.objects.get(pk=payload['id']) + except CustomUser.DoesNotExist: + msg = 'Пользователь соответствующий данному токену не найден.' + raise exceptions.AuthenticationFailed(msg) + + return user, token \ No newline at end of file diff --git a/src/jwtauth/forms.py b/src/jwtauth/forms.py new file mode 100644 index 0000000..f15014c --- /dev/null +++ b/src/jwtauth/forms.py @@ -0,0 +1,50 @@ +from django import forms +from django.forms.models import ModelForm +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.core.exceptions import ValidationError +from.models import CustomUser + + +class UserCreationForm(ModelForm): + """A form for creating new users. Includes all the required + fields, plus a repeated password.""" + password1 = forms.CharField(label='Пароль', widget=forms.PasswordInput) + password2 = forms.CharField(label='Подтверждение пароля', widget=forms.PasswordInput) + + class Meta: + model = CustomUser + fields = ('email', 'is_staff', 'is_superuser') + + def clean_password2(self): + # Check that the two password entries match + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise ValidationError("Пароли не совпадают") + return password2 + + def save(self, commit=True): + # Save the provided password in hashed format + user = super().save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class UserChangeForm(ModelForm): + """A form for updating users. Includes all the fields on + the user, but replaces the password field with admin's + password hash display field. + """ + password = ReadOnlyPasswordHashField() + + class Meta: + model = CustomUser + fields = ('email', 'password', 'is_staff') + + def clean_password(self): + # Regardless of what the user provides, return the initial value. + # This is done here, rather than on the field, because the + # field does not have access to the initial value + return self.initial["password"] \ No newline at end of file diff --git a/src/jwtauth/managers.py b/src/jwtauth/managers.py new file mode 100644 index 0000000..a854c88 --- /dev/null +++ b/src/jwtauth/managers.py @@ -0,0 +1,28 @@ +from django.contrib.auth.base_user import BaseUserManager + + +class CustomUserManager(BaseUserManager): + use_in_migrations = True + + def create_user(self, email, password=None): + + if not email: + raise ValueError(_('The email must be set')) + email = self.normalize_email(email) + user = self.model( + email=email, + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None): + + user = self.create_user( + email, + password=password + ) + user.is_staff = True + user.is_superuser = True + user.save(using=self._db) + return user \ No newline at end of file diff --git a/src/jwtauth/migrations/__init__.py b/src/jwtauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jwtauth/models.py b/src/jwtauth/models.py new file mode 100644 index 0000000..7f5185c --- /dev/null +++ b/src/jwtauth/models.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import PermissionsMixin +from django.db import models +from pytz import timezone + +from .managers import CustomUserManager +from .token_generators import generate_jwt + + +# Create your models here. + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + + class Meta: + verbose_name = 'User' + verbose_name_plural = 'Users' + + email = models.EmailField(verbose_name='email', unique=True) + is_staff = models.BooleanField(default=False, verbose_name='Administrator') + + USERNAME_FIELD = 'email' + + objects = CustomUserManager() + + def __str__(self): + return self.email + + @property + def access_token(self): + return generate_jwt(self.pk) + + +class RefreshToken(models.Model): + token = models.CharField(max_length=255) + expires = models.DateTimeField(default=datetime.now) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + used = models.BooleanField(default=False) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + self.expires = datetime.now(timezone(settings.TIME_ZONE)) + timedelta(**settings.REFRESH_TOKEN_LIFETIME) + super().save(force_insert, force_update, using, update_fields) + + def __str__(self): + return self.token + + +class Profile(models.Model): + user = models.OneToOneField(CustomUser, on_delete=models.CASCADE) + first_name = models.CharField(max_length=32, default='', blank=True) + last_name = models.CharField(max_length=32, default='', blank=True) + title = models.CharField(max_length=32, default='', blank=True) + avatar = models.ImageField(upload_to='avatar/', null=True, blank=True, default=None) + friends = models.ManyToManyField('self', blank=True, through='Friend') + + +class Friend(models.Model): + first = models.ForeignKey(Profile, on_delete=models.CASCADE) + second = models.ForeignKey(Profile, on_delete=models.CASCADE) + approved = models.BooleanField(default=False) diff --git a/src/jwtauth/renderers.py b/src/jwtauth/renderers.py new file mode 100644 index 0000000..94507c7 --- /dev/null +++ b/src/jwtauth/renderers.py @@ -0,0 +1,24 @@ +import json + +from rest_framework.renderers import JSONRenderer + + +class CustomUserJSONRenderer(JSONRenderer): + charset = 'utf-8' + + def render(self, data, accepted_media_type=None, renderer_context=None): + try: + errors = data.get('errors', None) + access_token = data.get('access_token', None) + + if errors is not None: + return super(CustomUserJSONRenderer, self).render(data) + + if access_token is not None and isinstance(access_token, bytes): + data['access_token'] = access_token.decode('utf-8') + + return json.dumps( + data + ) + except: + return None \ No newline at end of file diff --git a/src/jwtauth/repositories.py b/src/jwtauth/repositories.py new file mode 100644 index 0000000..d690944 --- /dev/null +++ b/src/jwtauth/repositories.py @@ -0,0 +1,39 @@ +from core.repositories import BaseRepository, ObjectDoesNotExist +from .models import CustomUser, Profile, RefreshToken + + +class UserRepository(BaseRepository): + model = CustomUser + + @classmethod + def get(cls, email): + try: + return cls.model.objects.get(email=email) + except ObjectDoesNotExist: + return None + + @classmethod + def update(cls, instance, **kwargs): + password = kwargs.pop('password', None) + for key, value in kwargs.items(): + setattr(instance, key, value) + if password: + instance.set_password(password) + instance.save() + return instance + + +class ProfileRepository(BaseRepository): + model = Profile + + @classmethod + def get_by_user(cls, user): + return cls.model.objects.get(user=user) + + +class RefreshTokenRepository(BaseRepository): + model = RefreshToken + + @classmethod + def get(cls, token): + return cls.model.objects.get(token=token) diff --git a/src/jwtauth/serializers.py b/src/jwtauth/serializers.py new file mode 100644 index 0000000..19fda9a --- /dev/null +++ b/src/jwtauth/serializers.py @@ -0,0 +1,118 @@ +from django.contrib.auth import authenticate + +from rest_framework import serializers + +from .models import CustomUser, Profile, RefreshToken +from .repositories import UserRepository, ProfileRepository, RefreshTokenRepository +from .token_generators import generate_rt + + +class RegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField( + max_length=128, + min_length=8, + write_only=True + ) + + access_token = serializers.CharField(max_length=255, read_only=True) + refresh_token = serializers.CharField(max_length=255, read_only=True) + + class Meta: + model = CustomUser + fields = ['email', 'password', 'access_token', 'refresh_token'] + + def create(self, validated_data): + user = UserRepository.create(**validated_data) + token = RefreshTokenRepository.create( + token=generate_rt(), + user=user + ) + return { + 'username': user.username, + 'access_token': user.access_token, + 'refresh_token': token.token + } + + +class LoginSerializer(serializers.Serializer): + email = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=128, write_only=True) + access_token = serializers.CharField(max_length=255, read_only=True) + refresh_token = serializers.StringRelatedField(read_only=True) + + def validate(self, attrs): + + email = attrs.get('email', None) + password = attrs.get('password', None) + + if email is None: + raise serializers.ValidationError( + str(attrs) + ) + + if password is None: + raise serializers.ValidationError( + 'A password is required to log in' + ) + + user = authenticate(username=email, password=password) + + if user is None: + raise serializers.ValidationError( + 'A user with this username and password was not found' + ) + + token = RefreshTokenRepository.create( + token=generate_rt(), + user=user + ) + + return { + "email": email, + 'access_token': user.access_token, + 'refresh_token': token.token + } + + +class CustomUserSerializer(serializers.ModelSerializer): + + password = serializers.CharField( + max_length=128, + min_length=8, + write_only=True + ) + + class Meta: + model = CustomUser + fields = ('email', 'is_staff', 'password') + read_only_fields = ('is_staff',) + + def update(self, instance, validated_data): + + password = validated_data.pop('password', None) + + for key, value in validated_data.items(): + setattr(instance, key, value) + + if password is not None: + instance.set_password(password) + + instance.save() + + return instance + + +class ProfileSerializer(serializers.ModelSerializer): + user = serializers.StringRelatedField() + friends = serializers.SerializerMethodField(read_only=True) + subscribers = serializers.SerializerMethodField(read_only=True) + + def get_friends(self, obj): + return obj.friends.filter(approved=True).count() + + def get_subscribers(self, obj): + return obj.friends.filter(approved=False).count() + + class Meta: + model = Profile + fields = ('user', 'first_name', 'last_name', 'title', 'avatar', 'friends', 'subscribers') diff --git a/src/jwtauth/tests.py b/src/jwtauth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/jwtauth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/jwtauth/token_generators.py b/src/jwtauth/token_generators.py new file mode 100644 index 0000000..3f94e68 --- /dev/null +++ b/src/jwtauth/token_generators.py @@ -0,0 +1,23 @@ +import datetime, random, string +import pytz + +from django.conf import settings +import jwt + + +def generate_jwt(pk): + dt = datetime.datetime.now(tz=pytz.timezone('Europe/Moscow')) + datetime.timedelta( + seconds=settings.TOKEN_LIFETIME['seconds'], + minutes=settings.TOKEN_LIFETIME['minutes'], + hours=settings.TOKEN_LIFETIME['hours'], + days=settings.TOKEN_LIFETIME['days'] + ) + token = jwt.api_jwt.encode({ + 'id': pk, + 'exp': dt, + }, settings.SECRET_KEY, algorithm='HS256') + return token + + +def generate_rt(): + return ''.join([random.choice(string.ascii_letters) for i in range(128)]) \ No newline at end of file diff --git a/src/jwtauth/urls.py b/src/jwtauth/urls.py new file mode 100644 index 0000000..e24782a --- /dev/null +++ b/src/jwtauth/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import RegistrationAPIView, LoginAPIView, CustomUserRetrieveUpdateAPIView, RefreshAPIView, ProfileRetrieveUpdateDestroyAPIView, ProfileRetrieveAPIView, ProfileListAPIView + +app_name = 'jwtauth' + +urlpatterns = [ + path('registration/', RegistrationAPIView.as_view()), + path('login/', LoginAPIView.as_view()), + path('refresh/', RefreshAPIView.as_view()), + path('profile/', ProfileRetrieveAPIView.as_view()), + path('profiles/', ProfileListAPIView.as_view()), + path('profile/', ProfileRetrieveUpdateDestroyAPIView.as_view()), + path('', CustomUserRetrieveUpdateAPIView.as_view()), +] diff --git a/src/jwtauth/views.py b/src/jwtauth/views.py new file mode 100644 index 0000000..235076c --- /dev/null +++ b/src/jwtauth/views.py @@ -0,0 +1,217 @@ +from datetime import datetime + +from django.conf import settings +from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from pytz import timezone +from rest_framework import status +from rest_framework.generics import RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, RetrieveAPIView, CreateAPIView, ListAPIView +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.permissions import IsAuthorOrReadOnly +from .models import CustomUser, RefreshToken +from .repositories import RefreshTokenRepository, ProfileRepository +from .serializers import RegistrationSerializer, LoginSerializer, CustomUserSerializer, ProfileSerializer +from .renderers import CustomUserJSONRenderer +from .token_generators import generate_rt, generate_jwt + + +# Create your views here. + + +class RefreshAPIView(APIView): + """ + Refreshing old access token + """ + permission_classes = (AllowAny,) + + @swagger_auto_schema(request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'refresh_token': openapi.Schema(type=openapi.TYPE_STRING, description='refresh_token'), + }, + required=['refresh_token'] + ), + responses={ + status.HTTP_200_OK: openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'access_token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh_token': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + } + ) + def post(self, request): + try: + refresh_token = request.data.get('refresh_token') + old_token = RefreshTokenRepository.get(token=refresh_token) + user = old_token.user + except RefreshToken.DoesNotExist: + return Response({ + 'error': 'Token is not valid' + }, status=status.HTTP_400_BAD_REQUEST) + if not old_token.used and old_token.expires > datetime.now(tz=timezone(settings.TIME_ZONE)): + token = RefreshToken.objects.create( + token=generate_rt(), + user=user + ) + old_token.delete() + data = { + 'access_token': generate_jwt(user.pk), + 'refresh_token': token.token + } + return Response(data, status=status.HTTP_200_OK) + else: + return Response( + data={ + 'error': 'Token expired', + }, + status=status.HTTP_400_BAD_REQUEST + ) + + +class RegistrationAPIView(APIView): + permission_classes = (AllowAny,) + serializer_class = RegistrationSerializer + renderer_classes = (CustomUserJSONRenderer,) + + @swagger_auto_schema(request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING, description='email'), + 'password': openapi.Schema(type=openapi.TYPE_STRING, description='user password') + } + ), + responses={ + status.HTTP_200_OK: openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'access_token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh_token': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + } + ) + def post(self, request): + user = request.data + serializer = self.serializer_class(data=user) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class LoginAPIView(APIView): + permission_classes = (AllowAny,) + serializer_class = LoginSerializer + renderer_classes = (CustomUserJSONRenderer,) + + @swagger_auto_schema(request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING, description='email'), + 'password': openapi.Schema(type=openapi.TYPE_STRING, description='user password') + }, + ), + responses={ + status.HTTP_200_OK: openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'access_token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh_token': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + } + ) + def post(self, request): + user = request.data + + serializer = self.serializer_class(data=user) + serializer.is_valid(raise_exception=True) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CustomUserRetrieveUpdateAPIView(RetrieveUpdateAPIView): + permission_classes = (IsAuthenticated,) + renderer_classes = (CustomUserJSONRenderer,) + serializer_class = CustomUserSerializer + + def retrieve(self, request, *args, **kwargs): + serializer = self.serializer_class(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) + + def update(self, request, *args, **kwargs): + serializer_data = request.data + serializer = self.serializer_class( + request.user, data=serializer_data, partial=True + ) + + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + + +# class ChangePasswordAPIView(APIView): +# +# permission_classes = (IsAuthenticated,) +# +# @swagger_auto_schema(request_body=openapi.Schema( +# type=openapi.TYPE_OBJECT, +# properties={ +# 'new_password': openapi.Schema(type=openapi.TYPE_STRING, description='refresh_token'), +# }, +# required=['new_password'] +# ), +# responses={ +# status.HTTP_200_OK: openapi.Schema( +# type=openapi.TYPE_OBJECT, +# properties={ +# 'message': 'password changed', +# } +# ), +# status.HTTP_401_UN +# } +# ) +# def post(self, request): +# pass + + +class ProfileRetrieveUpdateDestroyAPIView(CreateAPIView, RetrieveUpdateDestroyAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = ProfileSerializer + queryset = ProfileRepository.all() + + def get_queryset(self): + return self.queryset + + def get_object(self): + queryset = self.get_queryset() + obj = get_object_or_404(queryset, user=self.request.user) + return obj + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def perform_update(self, serializer): + serializer.save(user=self.request.user) + + +class ProfileRetrieveAPIView(RetrieveAPIView): + permission_classes = (IsAuthorOrReadOnly,) + serializer_class = ProfileSerializer + queryset = ProfileRepository.all() + + +class ProfileListAPIView(ListAPIView): + queryset = ProfileRepository.all() + serializer_class = ProfileSerializer + permission_classes = [IsAdminUser] + pagination_class = PageNumberPagination diff --git a/src/manage.py b/src/manage.py new file mode 100644 index 0000000..547e42b --- /dev/null +++ b/src/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'conf.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/src/organizations/__init__.py b/src/organizations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/organizations/admin.py b/src/organizations/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/src/organizations/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/organizations/apps.py b/src/organizations/apps.py new file mode 100644 index 0000000..fc11062 --- /dev/null +++ b/src/organizations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrganizationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'organizations' diff --git a/src/organizations/migrations/__init__.py b/src/organizations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/organizations/models.py b/src/organizations/models.py new file mode 100644 index 0000000..29a5a93 --- /dev/null +++ b/src/organizations/models.py @@ -0,0 +1,34 @@ +from django.db import models +from django.contrib.gis.db.models import PointField + +# Create your models here. + + +class Region(models.Model): + code = models.PositiveIntegerField() + name = models.CharField(max_length=64) + + +class Location(models.Model): + coords = PointField(db_index=True) + region = models.ForeignKey(Region, on_delete=models.SET_NULL, null=True) + address = models.CharField(max_length=512, blank=True, null=True) + + +class Category(models.Model): + name = models.CharField(max_length=64) + icon = models.ImageField(upload_to='icons/category/') + + +class Organization(models.Model): + location = models.ForeignKey(Location, on_delete=models.SET_NULL, null=True) + name = models.CharField(max_length=256) + phone = models.CharField(max_length=20) + website = models.URLField() + description = models.TextField() + owner = models.ForeignKey('jwtauth.CustomUser', on_delete=models.SET_NULL, null=True) + + +class OrganizationImage(models.Model): + image = models.ImageField(upload_to='images/organizations') + organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='organization_images') diff --git a/src/organizations/repositories.py b/src/organizations/repositories.py new file mode 100644 index 0000000..50f9149 --- /dev/null +++ b/src/organizations/repositories.py @@ -0,0 +1,30 @@ +from .models import Category, Location, Organization, OrganizationImage, Region +from core.repositories import BaseRepository + + +class CategoryRepository(BaseRepository): + model = Category + + +class LocationRepository(BaseRepository): + model = Location + + +class OrganizationRepository(BaseRepository): + model = Organization + + @classmethod + def search(cls, name): + return cls.model.objects.filter(name__icontains=name) + + @classmethod + def get_by_category(cls, category_id): + return cls.model.objects.filter(category__pk=category_id) + + +class OrganizationImagesRepository(BaseRepository): + model = OrganizationImage + + +class RegionRepository(BaseRepository): + model = Region diff --git a/src/organizations/serializers.py b/src/organizations/serializers.py new file mode 100644 index 0000000..bd00c29 --- /dev/null +++ b/src/organizations/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers + +from core.fields import LocationField +from jwtauth.serializers import CustomUserSerializer +from .models import Category, Location, Organization, OrganizationImage +from .repositories import OrganizationImagesRepository + + +class CategorySerializer(serializers.ModelSerializer): + + class Meta: + model = Category + fields = '__all__' + + +class LocationSerializer(serializers.ModelSerializer): + coords = LocationField() + region = serializers.StringRelatedField() + + class Meta: + model = Location + fields = ('coords', 'region', 'address') + + +class OrganizationListSerializer(serializers.ModelSerializer): + location = LocationSerializer() + images = serializers.StringRelatedField(many=True) + + class Meta: + model = Organization + fields = ('location', 'images', 'name', 'phone', 'description', 'website') + + +class OrganizationCreateSerializer(serializers.ModelSerializer): + location = LocationSerializer() + images = serializers.PrimaryKeyRelatedField(many=True, queryset=OrganizationImagesRepository) + owner = serializers.StringRelatedField() + + class Meta: + model = Organization + fields = ('location', 'images', 'name', 'phone', 'website', 'description', 'owner') diff --git a/src/organizations/tests.py b/src/organizations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/organizations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/organizations/urls.py b/src/organizations/urls.py new file mode 100644 index 0000000..3e676d0 --- /dev/null +++ b/src/organizations/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import OrganizationViewSet, OrganizationSearchListAPIView, OrganizationByCategoryListAPIView + +app_name = "organizations" + +router = DefaultRouter() +router.register('', OrganizationViewSet, 'organizations') +urlpatterns = [ + path('category//organizations/', OrganizationByCategoryListAPIView.as_view()), + path('search//', OrganizationSearchListAPIView.as_view()), +] + router.urls diff --git a/src/organizations/views.py b/src/organizations/views.py new file mode 100644 index 0000000..a4dac84 --- /dev/null +++ b/src/organizations/views.py @@ -0,0 +1,79 @@ +from django.conf import settings +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from django.views.decorators.vary import vary_on_cookie +from rest_framework.generics import ListAPIView +from rest_framework.pagination import PageNumberPagination +from rest_framework.viewsets import ModelViewSet + +from core.permissions import IsAuthorOrReadOnly +from .repositories import CategoryRepository, OrganizationRepository +from .serializers import CategorySerializer, OrganizationListSerializer, OrganizationCreateSerializer + + +# Create your views here. + + +class OrganizationViewSet(ModelViewSet): + pagination_class = PageNumberPagination + permission_classes = [IsAuthorOrReadOnly] + + @method_decorator(vary_on_cookie) + @method_decorator(cache_page(settings.CACHE_TTL)) + def dispatch(self, request, *args, **kwargs): + return super(OrganizationViewSet, self).dispatch(*args, **kwargs) + + def get_serializer_class(self): + if self.action == 'list' or self.action == 'retrieve': + return OrganizationListSerializer + else: + return OrganizationCreateSerializer + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer): + serializer.save(owner=self.request.user) + + +class OrganizationSearchListAPIView(ListAPIView): + pagination_class = PageNumberPagination + serializer_class = OrganizationListSerializer + + @method_decorator(vary_on_cookie) + @method_decorator(cache_page(settings.CACHE_TTL)) + def dispatch(self, request, *args, **kwargs): + return super(OrganizationSearchListAPIView, self).dispatch(*args, **kwargs) + + def get_queryset(self): + name = self.kwargs.get('name') + if name: + return OrganizationRepository.search(name) + else: + return OrganizationRepository.all() + + def list(self, request, *args, **kwargs): + return super().list(request, args, kwargs) + + +class CategoryListAPIView(ListAPIView): + pagination_class = PageNumberPagination + queryset = CategoryRepository.all() + serializer_class = CategorySerializer + + +class OrganizationByCategoryListAPIView(ListAPIView): + pagination_class = PageNumberPagination + serializer_class = OrganizationListSerializer + + @method_decorator(vary_on_cookie) + @method_decorator(cache_page(settings.CACHE_TTL)) + def dispatch(self, request, *args, **kwargs): + return super(OrganizationByCategoryListAPIView, self).dispatch(*args, **kwargs) + + def get_queryset(self): + category = self.kwargs.get('id') + if category: + return OrganizationRepository.get_by_category(int(category)) + else: + return OrganizationRepository.all() diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..446b380f5fd6fb8fb14c243422fd07a2f5bde21b GIT binary patch literal 684 zcmY*XO;3YB6r8h(KLrC=KQ10jJQ+5rl0&TIhMWE687K*OU%xy7p%lwGpm-9YJ`HAj(ibTWXn-<*IBGH z_K4Y8r+rfHF;Kl@&xWTD<5^pu_egc@?1+qTAXd)Bov@nPer2wf*wb+x{A>;5^_0li zFf|biG*m07T~pC9Be^NbI&it$|E~7btBq+x_QsH| zt~*ke?q13bza8&GFF|*f{V~tkC)XLX?uZhi<)4G4-ZH6_w=_5Rn($x