diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..211d2fe --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +ENVIRONMENT=dev|prod +SECRET_KEY= \ No newline at end of file diff --git a/manage.py b/manage_dev.py similarity index 98% rename from manage.py rename to manage_dev.py index 2c49f3a..8fc9bb9 100755 --- a/manage.py +++ b/manage_dev.py @@ -6,7 +6,7 @@ import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings.dev') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/project/serializers.py b/project/serializers.py new file mode 100644 index 0000000..5f9f10a --- /dev/null +++ b/project/serializers.py @@ -0,0 +1,4 @@ +from rest_framework import serializers + +class MessageResponseSerializer(serializers.Serializer): + detail = serializers.CharField() \ No newline at end of file diff --git a/project/settings/__init__.py b/project/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/settings.py b/project/settings/base.py similarity index 67% rename from project/settings.py rename to project/settings/base.py index c1d624c..74a4d42 100644 --- a/project/settings.py +++ b/project/settings/base.py @@ -1,7 +1,7 @@ """ Django settings for project project. -Generated by 'django-admin startproject' using Django 5.2.9. +Generated by 'django-admin startproject' using Django 5.2.8. For more information on this file, see https://docs.djangoproject.com/en/5.2/topics/settings/ @@ -10,20 +10,23 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.2/ref/settings/ """ +import os from pathlib import Path +import dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +dotenv_file = os.path.join(BASE_DIR, ".env") +if os.path.isfile(dotenv_file): + dotenv.load_dotenv(dotenv_file) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-l8klj*l0pb$ja!@0588%t21o237*m!dj2&1ij+_n0-&3c&5j6x' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +SECRET_KEY = os.environ['SECRET_KEY'] ALLOWED_HOSTS = [] @@ -37,9 +40,19 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'users.apps.UsersConfig', + + 'corsheaders', + 'rest_framework', + 'rest_framework.authtoken', + + 'django_filters', + 'drf_spectacular', + ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -51,6 +64,25 @@ MIDDLEWARE = [ ROOT_URLCONF = 'project.urls' +SPECTACULAR_SETTINGS = { + 'TITLE': 'Gallery API', + 'DESCRIPTION': 'Gallery project for agilni', + 'VERSION': '1.0.0', + 'COMPONENT_SPLIT_REQUEST': True +} + +CORS_ALLOW_ALL_ORIGINS = True + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], +} + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -69,17 +101,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'project.wsgi.application' -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - - # Password validation # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators @@ -115,8 +136,17 @@ USE_TZ = True # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_URL = 'static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static') + + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Custom user model +# https://docs.djangoproject.com/en/5.2/topics/auth/customizing/ +AUTH_USER_MODEL = 'users.User' \ No newline at end of file diff --git a/project/settings/dev.py b/project/settings/dev.py new file mode 100644 index 0000000..55db6aa --- /dev/null +++ b/project/settings/dev.py @@ -0,0 +1,12 @@ +from .base import * + +ALLOWED_HOSTS = ['*'] + +DEBUG = True + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} diff --git a/project/settings/prod.py b/project/settings/prod.py new file mode 100644 index 0000000..a8d10f8 --- /dev/null +++ b/project/settings/prod.py @@ -0,0 +1,3 @@ +from .base import * + +DEBUG = False \ No newline at end of file diff --git a/project/settings_context.py b/project/settings_context.py new file mode 100644 index 0000000..e507970 --- /dev/null +++ b/project/settings_context.py @@ -0,0 +1,7 @@ +import os +ENVIRONMENT = os.getenv('ENVIRONMENT', 'prod') + +if ENVIRONMENT == 'dev': + from project.settings.dev import * +else: + from project.settings.prod import * \ No newline at end of file diff --git a/project/urls.py b/project/urls.py index d1d4e61..356bd9d 100644 --- a/project/urls.py +++ b/project/urls.py @@ -14,9 +14,16 @@ 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 drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/', include([ + path('', include('users.urls')) + ])), ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0cc02e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +asgiref==3.11.0 +Django==5.2.9 +django-cors-headers==4.9.0 +django-rest-framework==0.1.0 +djangorestframework==3.16.1 +python-dotenv==1.2.1 +sqlparse==0.5.4 diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..bd4fbb1 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import User + +# Register your models here. +class UserAdmin(admin.ModelAdmin): + list_display = ('username', 'school_index', 'role', 'is_superuser', 'last_login', 'date_joined') + +admin.site.register(User, UserAdmin) \ No newline at end of file diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..72b1401 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..cf79353 --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.9 on 2025-12-03 12:25 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('common', 'Common'), ('prof', 'Professor')], default='common', max_length=20)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/users/migrations/0002_user_school_id.py b/users/migrations/0002_user_school_id.py new file mode 100644 index 0000000..4cf2a06 --- /dev/null +++ b/users/migrations/0002_user_school_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-03 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='school_id', + field=models.CharField(default=1, max_length=8), + preserve_default=False, + ), + ] diff --git a/users/migrations/0003_schoolid_remove_user_school_id_user_school_index.py b/users/migrations/0003_schoolid_remove_user_school_id_user_school_index.py new file mode 100644 index 0000000..00e321a --- /dev/null +++ b/users/migrations/0003_schoolid_remove_user_school_id_user_school_index.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2025-12-05 11:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_school_id'), + ] + + operations = [ + migrations.CreateModel( + name='SchoolID', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('school_index', models.CharField(db_index=True, max_length=8)), + ], + ), + migrations.RemoveField( + model_name='user', + name='school_id', + ), + migrations.AddField( + model_name='user', + name='school_index', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='users.schoolid'), + preserve_default=False, + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..0c63b3b --- /dev/null +++ b/users/models.py @@ -0,0 +1,28 @@ +from django.db import models +from django.db.models import Q +from django.contrib.auth.models import AbstractUser +from django.contrib.auth import authenticate + +# Create your models here. +class SchoolID(models.Model): + school_index = models.CharField(max_length=8, db_index=True, unique=True) + + def __str__(self): + return self.school_index + +class User(AbstractUser): + class Roles(models.TextChoices): + COMMON = 'common', 'Common' + PROFESSOR = 'prof', 'Professor' + + school_index = models.ForeignKey(SchoolID, on_delete=models.CASCADE) + role = models.CharField(max_length=20, choices=Roles, default=Roles.COMMON) + + @staticmethod + def authenticate(request, username_or_schoolid, password): + user = User.objects.filter(Q(username=username_or_schoolid) | Q(school_index__school_index=username_or_schoolid)).first() + + if not user: + return False + + return authenticate(request, username=user.username, password=password) diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..395b7ab --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,63 @@ +from rest_framework import serializers +from .models import User, SchoolID + +class SchoolIDSerializer(serializers.ModelSerializer): + class Meta: + model = SchoolID + fields = ('id', 'school_index') + +class UserSerializer(serializers.ModelSerializer): + is_superuser = serializers.BooleanField(read_only=True) + school_index_object = SchoolIDSerializer(source='school_index', read_only=True) + + class Meta: + model = User + fields = ('id', 'username', 'school_index_object', 'school_index', 'email', 'role', 'is_superuser') + +class UserForAdminSerializer(UserSerializer): + is_superuser = serializers.BooleanField() + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField() + +class TokenSerializer(serializers.Serializer): + token = serializers.CharField() + +class RegisterSerializer(serializers.Serializer): + username = serializers.CharField(max_length=150) + email = serializers.EmailField() + first_name = serializers.CharField(max_length=30, required=True, allow_blank=False) + last_name = serializers.CharField(max_length=30, required=True, allow_blank=False) + school_index = serializers.CharField(max_length=8, required=True) + password = serializers.CharField(write_only=True, min_length=8) + + def validate_school_index(self, value): + """ + Check that school with this id is exists + """ + try: + school = SchoolID.objects.get(school_index=value) + user_exists = User.objects.filter(school_index=school).exists() + + if user_exists: + raise serializers.ValidationError("User with this school ID already exists") + + except SchoolID.DoesNotExist: + raise serializers.ValidationError("This school id is not exists") + + return school + + def create(self, validated_data): + school = validated_data.pop('school_index') + + user = User.objects.create_user( + username=validated_data['username'], + email=validated_data['email'], + first_name=validated_data.get('first_name'), + last_name=validated_data.get('last_name'), + password=validated_data['password'], + school_index=school + ) + + return user \ No newline at end of file diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..1a7459a --- /dev/null +++ b/users/urls.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from django.urls import path, include + +from .views import * + +urlpatterns = [ + path('auth/', include([ + path('me/', AboutMeView.as_view()), + path('login/', LoginView.as_view()), + path('register/', RegisterView.as_view()), + ])), + + path('users/', include([ + path('/', UserAPIView.as_view()), + path('', UserListAPIView.as_view()) + ])), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..b52a9de --- /dev/null +++ b/users/views.py @@ -0,0 +1,88 @@ +from django.shortcuts import render +from django.shortcuts import get_object_or_404 + +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import RetrieveUpdateDestroyAPIView, ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.authtoken.models import Token + +from drf_spectacular.utils import extend_schema + +from project.serializers import MessageResponseSerializer +from .models import User +from .serializers import UserSerializer, LoginSerializer, RegisterSerializer, TokenSerializer, UserForAdminSerializer + +# Create your views here. + + +@extend_schema(tags=['Auth'], + description="Get current authenticated user") +class AboutMeView(RetrieveAPIView): + serializer_class = UserSerializer + + permission_classes = [IsAuthenticated] + + def get_object(self): + return self.request.user + + +class LoginView(APIView): + @extend_schema(tags=['Auth'], + description='Authenticate using login or password', + request=LoginSerializer, + responses={ + 200: TokenSerializer, + 400: MessageResponseSerializer + }) + def post(self, request: Request, format=None): + username = request.data.get('username') + password = request.data.get('password') + + user = User.authenticate(request, username, password) + + if not user: + return Response({"error": "Invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) + + token, created = Token.objects.get_or_create(user=user) + + return Response({ + "token": token.key + }) + +class RegisterView(APIView): + @extend_schema(tags=['Auth'], + description='Register new user using school_id', + request=RegisterSerializer, + responses={ + 201: MessageResponseSerializer, + 400: MessageResponseSerializer + }) + def post(self, request: Request, format=None): + serializer = RegisterSerializer(data=request.data) + + if serializer.is_valid(): + serializer.save() + + return Response({ + "message": "User was registered successfully" + }, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@extend_schema(tags=['Users'], + description='List of all current users') +class UserListAPIView(ListAPIView): + queryset = User.objects.all() + serializer_class = UserForAdminSerializer + permission_classes = [IsAdminUser] + +@extend_schema(tags=['Users'], + description='CRUD for specific user') +class UserAPIView(RetrieveUpdateDestroyAPIView): + queryset = User.objects.all() + serializer_class = UserForAdminSerializer + permission_classes = [IsAdminUser] \ No newline at end of file