diff --git a/.gitignore b/.gitignore index 21c7067..84e3e67 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ venv.bak/ .pyre/ # VS Code settings .vscode + +# Backend gitignore +/backend/.gitignore \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..e0e4336 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn Transcripts.wsgi --log-file - \ No newline at end of file diff --git a/backend/ApiList.txt b/backend/ApiList.txt new file mode 100644 index 0000000..5c6c8b5 --- /dev/null +++ b/backend/ApiList.txt @@ -0,0 +1,66 @@ +1) User creation +request :- http://127.0.0.1:8000/account/auth/users/ +type :- POST +inputs :- { + "email":"", + "is_management":, + "password":"", + "re_password":"" + } +output :- { + "is_management": , + "email": "", + "id": + } + +2) User Activation +request :- http://127.0.0.1:8000/account/auth/users/activation/ +type :- POST + Send a mail on given email + http://127.0.0.1:8000/activate/(uid)/{token} +inputs :- { + "uid":"", + "token":"" + } + +3) User Login using JWT +request :- http://127.0.0.1:8000/account/auth/jwt/create/ +type :- POST +inputs :- { + "email":"", + "password":"" + } +output :- { + "refresh": "", + "access": "" + } + +4) User info +request :- http://127.0.0.1:8000/account/auth/me/ +type :- Get +output :- { + "email": "", + "is_management": , + "profile_created": + } + +5) User Reset password +request :- http://127.0.0.1:8000/account/auth/users/reset_password/ +type :- POST +inputs :- { + "email":"" + } +output :- send a mail on given email + http://127.0.0.1:8000/password/reset/confirm/{uid}/{token} + + +6) Confirm Reset Password +request :- http://127.0.0.1:8000/account/auth/users/reset_password_confirm/ +type :- POST +inputs :- { + "uid":"", + "token":"", + "new_password":"", + "re_new_password":"" + } + \ No newline at end of file diff --git a/backend/Transcripts/__init__.py b/backend/Transcripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/Transcripts/asgi.py b/backend/Transcripts/asgi.py new file mode 100644 index 0000000..b992ede --- /dev/null +++ b/backend/Transcripts/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for Transcripts 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/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Transcripts.settings') + +application = get_asgi_application() diff --git a/backend/Transcripts/settings.py b/backend/Transcripts/settings.py new file mode 100644 index 0000000..b090b0f --- /dev/null +++ b/backend/Transcripts/settings.py @@ -0,0 +1,201 @@ +""" +Django settings for Transcripts project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +from pathlib import Path +from decouple import config +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +config.encoding = "cp1251" +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config("SECRET_KEY") + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] +if DEBUG: + ALLOWED_HOSTS = ["*"] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # apps + "rest_framework", + "djoser", + "accounts", + "core", + # swagger + "corsheaders", + "drf_yasg", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "Transcripts.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 = "Transcripts.wsgi.application" + +# Email Setup + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "smtp.gmail.com" +EMAIL_PORT = 587 +EMAIL_HOST_USER = config("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.1/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/3.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ +STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = "/static/" + +MEDIA_URL = "/" +MEDIA_ROOT = os.path.join(BASE_DIR, "Images") + + +AUTH_USER_MODEL = "accounts.AppUser" +# to check if user has profile +# AUTH_PROFILE_MODEL = 'accounts.modelname' + +# simple jwt setup + +SIMPLE_JWT = { + "AUTH_HEADER_TYPES": ("Bearer",), +} + + +# REST FRAMEWORK SETUP + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), +} + +FRONTEND_DOMAIN = "www.abc.com" +# DJOSER SETUP +DJOSER = { + "LOGIN_FIELD": "email", + "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", + "ACTIVATION_URL": "activate/{uid}/{token}", + "SEND_ACTIVATION_EMAIL": True, + "SEND_CONFIRMATION_EMAIL": True, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, + "PASSWORD_RESET_CONFIRM_RETYPE": True, + "SEND_CONFIRMATION_EMAIL": True, + "USER_CREATE_PASSWORD_RETYPE": True, + "SET_PASSWORD_RETYPE": True, + "SERIALIZERS": { + "user_create": "accounts.serializers.UserCreateSerializer", + "user": "accounts.serializers.UserCreateSerializer", + "user_delete": "djoser.serializers.UserDeleteSerializer", + }, + "EMAIL": { + "activation": "accounts.email.ActivationEmail", + "confirmation": "accounts.email.ConfirmationEmail", + "password_reset": "accounts.email.PasswordResetEmail", + "password_changed_confirmation": "accounts.email.PasswordChangedConfirmationEmail", + "username_changed_confirmation": "accounts.email.UsernameChangedConfirmationEmail", + "username_reset": "accounts.email.UsernameResetEmail", + }, +} + +# swagger +SWAGGER_SETTINGS = { + "SECURITY_DEFINITIONS": { + "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} + }, + "REFETCH_SCHEMA_WITH_AUTH": True, +} + +STATICFILES_STORAGE = "whitenoise.django.GzipManifestStaticFilesStorage" diff --git a/backend/Transcripts/urls.py b/backend/Transcripts/urls.py new file mode 100644 index 0000000..3da4d90 --- /dev/null +++ b/backend/Transcripts/urls.py @@ -0,0 +1,60 @@ +"""Transcripts URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/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.urls.static import static +from django.conf import settings +from django.contrib import admin +from django.urls import path, include + +# swagger +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="APP API", + default_version="v1", + description="Test description", + terms_of_service="https://www.ourapp.com/policies/terms/", + contact=openapi.Contact(email="contact@me.local"), + license=openapi.License(name="Test License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("accounts.urls")), + path("api/", include("core.urls")), + # swagger + path("", schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui"), + path( + "api/api.json/", + schema_view.without_ui(cache_timeout=0), + name="schema-swagger-ui", + ), + path("redoc/", schema_view.with_ui("redoc", + cache_timeout=0), name="schema-redoc"), +] +urlpatterns = urlpatterns + static( + settings.MEDIA_URL, document_root=settings.MEDIA_ROOT +) + +# urlpatterns += [re_path(r'^(?!api/).*', TemplateView.as_view(template_name="index.html"))] for react in prod if deploying on same server diff --git a/backend/Transcripts/wsgi.py b/backend/Transcripts/wsgi.py new file mode 100644 index 0000000..b704578 --- /dev/null +++ b/backend/Transcripts/wsgi.py @@ -0,0 +1,18 @@ +""" +WSGI config for Transcripts 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/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application +from whitenoise.django import DjangoWhiteNoise + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Transcripts.settings') + +application = get_wsgi_application() +application = DjangoWhiteNoise(application) \ No newline at end of file diff --git a/backend/accounts/__init__.py b/backend/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py new file mode 100644 index 0000000..fcd6041 --- /dev/null +++ b/backend/accounts/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from .models import * +from django.contrib.auth.admin import UserAdmin +from .forms import CustomUserChangeForm,CustomUserCreationForm +# Register your models here. +class myUserAdmin(UserAdmin): + add_form = CustomUserCreationForm + form = CustomUserChangeForm + model = AppUser + list_display=('email','date_joined','last_login','is_admin','is_management') + search_fields=('email',) + readonly_fields=('date_joined','last_login') + ordering=('email',) + filter_horizontal=() + list_filter=('email','is_staff','is_active','is_management',) + fieldsets=( + (None,{'fields':('email','password','is_management')}), + ('Permission',{'fields':('is_staff','is_active')}), + ) + add_fieldsets = ( + (None,{ + 'classes':('wide',), + 'fields':('email','password1','password2','is_management','is_staff','is_active') + }), + ) + +admin.site.register(AppUser,myUserAdmin) +admin.site.register(StudentProfile) +admin.site.register(ManagementProfile) \ No newline at end of file diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/backend/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/backend/accounts/email.py b/backend/accounts/email.py new file mode 100644 index 0000000..d967663 --- /dev/null +++ b/backend/accounts/email.py @@ -0,0 +1,31 @@ +from djoser.email import ActivationEmail, ConfirmationEmail, PasswordResetEmail, PasswordChangedConfirmationEmail, UsernameChangedConfirmationEmail, UsernameResetEmail +from django.conf import settings + +def customContext(context): + context['domain'] = settings.FRONTEND_DOMAIN + context['site_name'] = settings.FRONTEND_DOMAIN + return context +class ActivationEmail(ActivationEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) +class ConfirmationEmail(ConfirmationEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) +class PasswordResetEmail(PasswordResetEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) +class PasswordChangedConfirmationEmail(PasswordChangedConfirmationEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) +class UsernameChangedConfirmationEmail(UsernameChangedConfirmationEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) +class UsernameResetEmail(UsernameResetEmail): + def get_context_data(self): + context = super().get_context_data() + return customContext(context) \ No newline at end of file diff --git a/backend/accounts/forms.py b/backend/accounts/forms.py new file mode 100644 index 0000000..c33a211 --- /dev/null +++ b/backend/accounts/forms.py @@ -0,0 +1,14 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm , UserChangeForm +from .models import AppUser +class CustomUserCreationForm(UserCreationForm): + email = forms.EmailField(max_length=255,help_text='Required. Add a valid email address') + class meta(UserCreationForm) : + model = AppUser + fields = ('email',) + +class CustomUserChangeForm(UserChangeForm): + email = forms.EmailField(max_length=255,help_text='Required. Add a valid email address') + class meta : + model = AppUser + fields = ('email',) \ No newline at end of file diff --git a/backend/accounts/migrations/0001_initial.py b/backend/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..3ae4f05 --- /dev/null +++ b/backend/accounts/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 3.1.1 on 2021-02-08 04:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AppUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('email', models.EmailField(max_length=255, unique=True, verbose_name='email')), + ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date join')), + ('last_login', models.DateTimeField(auto_now=True, verbose_name='last login')), + ('is_admin', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_superuser', models.BooleanField(default=False)), + ('is_management', models.BooleanField(default=False)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StudentProfile', + fields=[ + ('sap_id', models.CharField(max_length=15, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('contact_no', models.CharField(max_length=13)), + ('department', models.CharField(choices=[('CS', 'COMPUTERS'), ('IT', 'INFORMATION TECHNOLOGY'), ('EXTC', 'ELECTRONICS AND TELECOMMUNICATION'), ('ELEX', 'ELECTRONICS'), ('MECH', 'MECHANICAL'), ('CHEM', 'CHEMICAL'), ('BIOMED', 'BIOMED'), ('PROD', 'PRODUCTION'), ('OTHERS', 'OTHERS')], max_length=40)), + ('admission_year', models.CharField(max_length=5)), + ('marksheet', jsonfield.fields.JSONField(default=dict)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ManagementProfile', + fields=[ + ('staff_id', models.CharField(max_length=20, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('contact_no', models.CharField(max_length=13)), + ('accepted', models.IntegerField(default=0)), + ('rejected', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/accounts/migrations/0002_application.py b/backend/accounts/migrations/0002_application.py new file mode 100644 index 0000000..173d467 --- /dev/null +++ b/backend/accounts/migrations/0002_application.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.5 on 2021-02-18 04:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('application_number', models.AutoField(primary_key=True, serialize=False)), + ('date_of_application', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('In Queue', 'In Queue'), ('In Review', 'In Review'), ('Rejected', 'Rejected'), ('Accepted', 'Accepted')], max_length=40)), + ('comment', models.TextField(blank=True, null=True)), + ('in_review', models.BooleanField(default=False)), + ('application_file', models.FileField(upload_to='')), + ('faculty', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.managementprofile')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.studentprofile')), + ], + ), + ] diff --git a/backend/accounts/migrations/0003_delete_application.py b/backend/accounts/migrations/0003_delete_application.py new file mode 100644 index 0000000..2b1b682 --- /dev/null +++ b/backend/accounts/migrations/0003_delete_application.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.5 on 2021-02-18 07:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_application'), + ] + + operations = [ + migrations.DeleteModel( + name='Application', + ), + ] diff --git a/backend/accounts/migrations/__init__.py b/backend/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/models.py b/backend/accounts/models.py new file mode 100644 index 0000000..b4e75ac --- /dev/null +++ b/backend/accounts/models.py @@ -0,0 +1,104 @@ +from django.db import models +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager +import jsonfield + + +# Create your models here. +# User Starts # +class AppUserManager(BaseUserManager): + def create_user(self, email,is_management, password=None, **extra_fields): + if email is None: + raise TypeError("Email is required") + user = self.model( + email=self.normalize_email(email), + is_management=is_management, + ) + + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email,is_management, password=None, **extra_fields): + if password is None: + raise TypeError('Password should not be none') + user = self.create_user( + email=self.normalize_email(email), + password=password, + is_management=is_management, + ) + user.is_active = True + user.is_superuser = True + user.is_staff = True + user.is_admin = True + user.save(using=self._db) + return user + + +class AppUser(AbstractBaseUser): + email = models.EmailField(verbose_name="email",max_length=255,unique=True) + date_joined = models.DateTimeField(verbose_name="date join",auto_now_add=True) + last_login = models.DateTimeField(verbose_name="last login",auto_now=True) + is_admin = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_superuser = models.BooleanField(default=False) + is_management = models.BooleanField(default=False) + + USERNAME_FIELD = 'email' + objects = AppUserManager() + REQUIRED_FIELDS = ['is_management'] + + @property + def profileCreated(self): + user = self + if user.is_management : + return ManagementProfile.objects.filter(user=user).exists() + else : + return StudentProfile.objects.filter(user=user).exists() + def __str__(self): + return self.email + + def has_perm(self,perm,obj=None): + return self.is_admin + + def has_module_perms(self,app_label): + return True + + +class StudentProfile(models.Model): + departments = ( + ('CS', 'COMPUTERS'), + ('IT', 'INFORMATION TECHNOLOGY'), + ('EXTC', 'ELECTRONICS AND TELECOMMUNICATION'), + ('ELEX', 'ELECTRONICS'), + ('MECH', 'MECHANICAL'), + ('CHEM', 'CHEMICAL'), + ('BIOMED', 'BIOMED'), + ('PROD', 'PRODUCTION'), + ('OTHERS', 'OTHERS'), + ) + user = models.OneToOneField(AppUser, on_delete=models.CASCADE) + # other details + sap_id = models.CharField(max_length=15, primary_key=True) + name = models.CharField(max_length=100) + contact_no = models.CharField(max_length=13) + department = models.CharField(max_length=40, choices=departments) + # can also take date as the input (change it to datefield) + admission_year = models.CharField(max_length=5) + marksheet = jsonfield.JSONField() + + def __str__(self): + return self.name + + +class ManagementProfile(models.Model): + user = models.OneToOneField(AppUser, on_delete=models.CASCADE) + staff_id = models.CharField(max_length=20, primary_key=True) + name = models.CharField(max_length=100) + contact_no = models.CharField(max_length=13) + accepted = models.IntegerField(default=0) + rejected = models.IntegerField(default=0) + + def __str__(self): + return self.name + diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py new file mode 100644 index 0000000..ea183ef --- /dev/null +++ b/backend/accounts/serializers.py @@ -0,0 +1,23 @@ +from djoser.serializers import UserCreateSerializer +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from .models import ManagementProfile, StudentProfile + +User = get_user_model() + +class UserCreateSerializer(UserCreateSerializer): + class Meta(UserCreateSerializer.Meta): + model = User + fields = ('id','email','is_management','password') + +class ManagementProfileSerializer(serializers.ModelSerializer): + class Meta: + model = ManagementProfile + fields = ('user', 'staff_id', 'name', 'contact_no', 'accepted', 'rejected') + +class StudentProfileSerializer(serializers.ModelSerializer): + class Meta: + model = StudentProfile + fields = ('user', 'sap_id', 'name', 'contact_no', 'department', 'admission_year') + \ No newline at end of file diff --git a/backend/accounts/tests.py b/backend/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/accounts/urls.py b/backend/accounts/urls.py new file mode 100644 index 0000000..8383e25 --- /dev/null +++ b/backend/accounts/urls.py @@ -0,0 +1,14 @@ + +from django.urls import path,include +from .views import * +from rest_framework.routers import DefaultRouter + +urlpatterns = [ + path('auth/', include('djoser.urls.jwt')), + path('auth/me/', UserInfoApi.as_view(),name='user_info'), +] + +router = DefaultRouter() +router.register("auth/users", UserViewSet) + +urlpatterns += router.urls \ No newline at end of file diff --git a/backend/accounts/views.py b/backend/accounts/views.py new file mode 100644 index 0000000..56f3dd2 --- /dev/null +++ b/backend/accounts/views.py @@ -0,0 +1,60 @@ +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import status +from rest_framework.exceptions import ValidationError +from .models import ManagementProfile, StudentProfile +from .serializers import ManagementProfileSerializer, StudentProfileSerializer +from djoser.views import UserViewSet +from djoser import signals +from djoser.compat import get_user_email +from djoser.conf import settings as DjoserSettings + +# swagger +# from drf_yasg.utils import swagger_auto_schema +# from drf_yasg import openapi + +# Create your views here. +class UserInfoApi(APIView): + permission_classes = [IsAuthenticated] + def get(self,request): + user = request.user + data = { + 'email': user.email, + 'is_management':user.is_management, + 'profile_created':user.profileCreated + } + return Response(data) + +class UserViewSet(UserViewSet): + # @swagger_auto_schema(method='post', request_body=openapi.Schema( + # type=openapi.TYPE_OBJECT, + # properties={ + # 'x': openapi.Schema(type=openapi.TYPE_STRING, description='string'), + # 'y': openapi.Schema(type=openapi.TYPE_STRING, description='string'), + # })) + def perform_create(self, serializer): + user = serializer.save() + signals.user_registered.send( + sender=self.__class__, user=user, request=self.request + ) + if self.request.data.get('profile', False): + self.request.data['profile']['user'] = user.id + if self.request.data.get('is_management', False): + profile = ManagementProfileSerializer(data = self.request.data.get('profile', None)) + else: + profile = StudentProfileSerializer(data = self.request.data.get('profile', None)) + if not profile.is_valid(): + user.delete() + raise ValidationError(profile.errors) + profile.save() + # Calling super().perform_create(serializer) saves serializer again and messes up password + + # Send activation email - + context = {"user": user} + to = [get_user_email(user)] + if DjoserSettings.SEND_ACTIVATION_EMAIL: + DjoserSettings.EMAIL.activation(self.request, context).send(to) + elif DjoserSettings.SEND_CONFIRMATION_EMAIL: + DjoserSettings.EMAIL.confirmation(self.request, context).send(to) \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/admin.py b/backend/core/admin.py new file mode 100644 index 0000000..cc2fb49 --- /dev/null +++ b/backend/core/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import * +# Register your models here. + +admin.site.register(Application) + +admin.site.register(Marksheet) + diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..26f78a8 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py new file mode 100644 index 0000000..22b6846 --- /dev/null +++ b/backend/core/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2021-02-22 07:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('review_time', models.DateTimeField(blank=True, default=None, null=True)), + ('comment', models.TextField(blank=True, null=True)), + ('in_review', models.BooleanField(default=None, null=True)), + ('accepted', models.BooleanField(default=False)), + ('faculty', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.managementprofile')), + ('student', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.studentprofile')), + ], + ), + ] diff --git a/backend/core/migrations/0002_auto_20210222_1455.py b/backend/core/migrations/0002_auto_20210222_1455.py new file mode 100644 index 0000000..240d1a3 --- /dev/null +++ b/backend/core/migrations/0002_auto_20210222_1455.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.5 on 2021-02-22 09:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ('core', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='faculty', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.managementprofile'), + ), + migrations.AlterField( + model_name='application', + name='student', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.studentprofile'), + ), + ] diff --git a/backend/core/migrations/0003_marksheet.py b/backend/core/migrations/0003_marksheet.py new file mode 100644 index 0000000..d8b3181 --- /dev/null +++ b/backend/core/migrations/0003_marksheet.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.5 on 2021-02-22 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_auto_20210222_1455'), + ] + + operations = [ + migrations.CreateModel( + name='Marksheet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.ImageField(blank=True, null=True, upload_to='images')), + ], + ), + ] diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 0000000..19e75ca --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,40 @@ +from django.db import models +from accounts.models import StudentProfile, ManagementProfile + +from django.db.models.signals import post_delete,post_save +from django.dispatch import receiver + + +class Marksheet(models.Model): + file = models.ImageField( upload_to='marks' ,null=True,blank=True) + +@receiver(post_delete, sender=Marksheet) +def submission_delete(sender, instance, **kwargs): + instance.file.delete(False) + + + + +class Application(models.Model): + #ID will be application number + student = models.ForeignKey(StudentProfile, null=True, blank=True, on_delete=models.SET_NULL, default=None) #Student applies + faculty = models.ForeignKey(ManagementProfile,null=True, blank=True, on_delete=models.SET_NULL, default=None) #Faculty checks + created_at = models.DateTimeField(auto_now_add=True) + review_time = models.DateTimeField(null=True, blank=True, default=None) #To track when something went in review + comment = models.TextField(blank=True, null=True) + in_review = models.BooleanField(null=True, default=None) + accepted = models.BooleanField(default=False) + + # in_review None => Created but hasn't been sent for review + # in_review True => Sent for (and in) review + # in_review False => Finished Reviewing + +def save_application(instance, created, **kwargs): + if (not created) and (instance.in_review is False): #instance.in_review is None has a different meaning so can't use not instance.in_review + if instance.accepted: + instance.faculty.accepted+=1 + else: + instance.faculty.rejected+=1 + instance.faculty.save() +post_save.connect(save_application, sender=Application) + diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 0000000..95fcddf --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers +from accounts.models import StudentProfile, ManagementProfile, AppUser +from .models import Application,Marksheet + +# delete this serializer if everthing works fine +# class UpdateStudentProfileSerializer(serializers.ModelSerializer): +# class Meta: +# model = StudentProfile +# fields = ('sap_id', "name", "contact_no", "department", 'admission_year') + +# class UpdateManagementProfileSerializer(serializers.ModelSerializer): +# class Meta: +# model = ManagementProfile +# fields= ('staff_id', 'name','contact_no') + +# donot delete this serializer +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = AppUser + fields = ('email',) + + + + +class ApplicationSerializer(serializers.ModelSerializer): + marksheet = serializers.SerializerMethodField('get_marks') + def get_marks(self, obj): + return obj.student.marksheet + class Meta: + model = Application + fields = ('id', 'student','faculty', 'in_review', 'created_at','accepted', 'comment', 'marksheet') + +class AcceptedSerializer(serializers.ModelSerializer): + ''' + Since we are sending a list of many accepted applications, there may potentially be + redundant data sent over network if we use ApplicationSerializer for it + ''' + class Meta: + model = Application + fields = ('id', 'student','faculty') + +class EnterResultSerializer(serializers.Serializer): + marksheet = serializers.JSONField() + + +class UploadMarksheetSerializer(serializers.ModelSerializer): + class Meta: + model = Marksheet + fields = ['file'] diff --git a/backend/core/tests.py b/backend/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..aedd34d --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,18 @@ +from django.urls import path,include +from .views import * +from .views_student import * +from .views_management import ManagementApplication, AcceptedApplications + +urlpatterns = [ + path('student/profile/', StudentUpdateProfile.as_view(), name='studentprofile'), + path('management/profile/', ManagementUpdateProfile.as_view(), name='managementprofile'), + #Management Dashboard: + path('management/applications/', ManagementApplication.as_view()), + path('management/accepted/', AcceptedApplications.as_view()), + + #Student Dashboard : + path('student/applications/', StudentApplication.as_view()), + path('student/scan_marksheet/', ScanMarksheet.as_view(),name="scan_marksheer"), + path('student/marks/',EnterMarks.as_view(),name="Enter marks"), + +] \ No newline at end of file diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..894ada1 --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,57 @@ +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from .serializers import * +from accounts.serializers import ManagementProfileSerializer, StudentProfileSerializer +from rest_framework.response import Response +from rest_framework import status + +# Create your views here. + +# student profile section +class StudentUpdateProfile(APIView): + permission_classes = [IsAuthenticated] + def put(self, request): + user = request.user + users_data = StudentProfile.objects.get(user= user) + user_serializer = StudentProfileSerializer(users_data, data=request.data) + if user_serializer.is_valid(): + user_serializer.save() + return Response(user_serializer.data,status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_404_NOT_FOUND) + + def get(self, request): + ''' + abc + ''' + user = request.user + # objects + users_data = StudentProfile.objects.get(user= user) + user_email_obj = users_data.user + user_serializer = UserSerializer(user_email_obj) + user_profile_serializer = StudentProfileSerializer(users_data) + return Response({'email':user_serializer.data,"profile":user_profile_serializer.data},status=status.HTTP_200_OK) + +class ManagementUpdateProfile(APIView): + permission_classes = [IsAuthenticated] + def get(self, request): + user = request.user + # objects + users_data = ManagementProfile.objects.get(user= user) + user_email_obj = users_data.user + user_serializer = UserSerializer(user_email_obj) + user_profile_serializer = ManagementProfileSerializer(users_data) + return Response({'email':user_serializer.data,"profile":user_profile_serializer.data},status=status.HTTP_200_OK) + + def put(self, request): + user = request.user + users_data = ManagementProfile.objects.get(user= user) + user_serializer = ManagementProfileSerializer(users_data, data=request.data) + if user_serializer.is_valid(): + user_serializer.save() + return Response(user_serializer.data,status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_404_NOT_FOUND) + + diff --git a/backend/core/views_management.py b/backend/core/views_management.py new file mode 100644 index 0000000..33bd8f0 --- /dev/null +++ b/backend/core/views_management.py @@ -0,0 +1,63 @@ +from django.shortcuts import render +from django.db.models import Q +from django.utils import timezone +from django.http import Http404 + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import status + +from datetime import timedelta +from .serializers import ApplicationSerializer, AcceptedSerializer +from .models import Application + +class ManagementApplication(APIView): + permisison_classes = [IsAuthenticated] + def get(self, request): + ''' + Get next application to review from queue + ''' + if not request.user.is_management: + return Response(status=status.HTTP_403_FORBIDDEN) + last_hour = timezone.now() - timedelta(hours=1) #Tested properly with seconds=10 + query = Q(in_review=None) | Q(in_review=True, review_time__lt=last_hour) + obj = Application.objects.filter(query).order_by('created_at').first() + if obj is None: + return Response(status=status.HTTP_204_NO_CONTENT) + obj.in_review = True + obj.review_time = timezone.now() + obj.save() + ser = ApplicationSerializer(obj) + return Response(ser.data, status=status.HTTP_200_OK) + def get_object(self, pk): + try: + return Application.objects.get(pk=pk) + except: + raise Http404 + def put(self, request): + ''' + Update existing application - accept or reject with comment + ''' + if not request.user.is_management: + return Response(status=status.HTTP_403_FORBIDDEN) + application = self.get_object(request.data.get('pk', None)) + request.data['in_review'] = False + request.data['faculty'] = request.user.managementprofile.staff_id + serializer = ApplicationSerializer(application, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_400_BAD_REQUEST) + +class AcceptedApplications(APIView): + permission_classes = [IsAuthenticated] + def get(self, request): + ''' + Get list of accepted applications to verify + ''' + if not request.user.is_management: + return Response(status=status.HTTP_403_FORBIDDEN) + qs = Application.objects.filter(accepted=True) + ser = AcceptedSerializer(qs, many=True) + return Response(ser.data, status=status.HTTP_200_OK) diff --git a/backend/core/views_student.py b/backend/core/views_student.py new file mode 100644 index 0000000..dd16d21 --- /dev/null +++ b/backend/core/views_student.py @@ -0,0 +1,268 @@ +from accounts.models import * +from .models import * +from rest_framework.views import APIView +from rest_framework.status import * +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework import authentication, permissions +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import FormParser,MultiPartParser,JSONParser,FileUploadParser +from .serializers import * + + +class StudentApplication(APIView): + permission_classes = [IsAuthenticated] + def get(self,request): + user = request.user + if(user.is_management==False): + if(StudentProfile.objects.all().filter(user=user).exists()): + student = StudentProfile.objects.get(user=user) + if(Application.objects.all().filter(student=student).exists()): + Old_application = Application.objects.all().filter(student=student) + d = {"application" : []} + for app in Old_application : + data={} + if(app.in_review==False): + + data = { + "Student Name":student.name, + "Faculty":app.faculty.name, + "created_at" :app.created_at, + "review_time":app.review_time, + "comment":app.comment, + "accepted":app.accepted + } + else: + data = { + "Student Name":student.name, + "created_at" :app.created_at, + } + d["application"].append(data) + return Response({"application": d},status=HTTP_200_OK) + else: + return Response({"message": "Applications Not Created"},status=HTTP_404_NOT_FOUND) + else: + return Response({"message": "Profile not created"},status=HTTP_400_BAD_REQUEST) + else: + return Response({"message": "You are not a student"},status=HTTP_400_BAD_REQUEST) + + def post(self,request): + user = request.user + if(user.is_management==False): + if(StudentProfile.objects.all().filter(user=user).exists()): + new_application=None + student = StudentProfile.objects.get(user=user) + if(Application.objects.all().filter(student=student).exists()): + old_application = Application.objects.all().filter(student=student) + last =len(old_application)-1 + application = old_application[last] + if application.in_review==False : + new_application = Application.objects.create(student=student) + + else: + return Response({"message": "Your Apllication is in Queue . You can't create 2 applications at time "},status=HTTP_208_ALREADY_REPORTED) + else: + new_application=Application.objects.create(student=student) + data = { + "Student Name":student.name, + "created_at" :new_application.created_at, + } + return Response({"message": "Application created","Application":data},status=HTTP_201_CREATED) + else: + return Response({"message": "Profile not created"},status=HTTP_400_BAD_REQUEST) + else: + return Response({"message": "You are not a student"},status=HTTP_400_BAD_REQUEST) + + +class EnterMarks(APIView): + permission_classes = [IsAuthenticated] + parser_classes=[JSONParser] + serializer_class = EnterResultSerializer + + def post(self,request): + user = request.user + if(StudentProfile.objects.all().filter(user=user).exists()): + student = StudentProfile.objects.get(user=user) + old_marksheet = student.marksheet + new_marksheet = {} + serializer=self.serializer_class(data=request.data) + if(serializer.is_valid()): + marks = serializer.data.get("marksheet") + + if(len(old_marksheet)>0): + if(len(old_marksheet)==8): + return Response({"message": "Marksheet Must contain atmost 8 semester"},status=HTTP_400_BAD_REQUEST) + new_marksheet = old_marksheet.copy() + sem_no = str(marks["sem"]) + new_marksheet[sem_no]=marks + new_marksheet = sort_dict(new_marksheet) + else: + sem_no = marks["sem"] + new_marksheet[sem_no]=marks + student.marksheet = new_marksheet + student.save() + return Response({"result":new_marksheet},status=HTTP_200_OK) + else: + return Response({"message": serializer.error_messages},status=HTTP_400_BAD_REQUEST) + else: + return Response({"message": "Profile not created"},status=HTTP_400_BAD_REQUEST) + + def get(self,request): + user = request.user + if(StudentProfile.objects.all().filter(user=user).exists()): + student = StudentProfile.objects.get(user=user) + old_marksheet = student.marksheet + return Response(old_marksheet,status=HTTP_200_OK) + else: + return Response({"message": "Profile not created"},status=HTTP_400_BAD_REQUEST) + + +class ScanMarksheet(APIView): + permission_classes = [IsAuthenticated] + serializer_class = UploadMarksheetSerializer + parser_classes=[MultiPartParser,JSONParser] + def post(self,request): + serializer = self.serializer_class(data=request.data) + if(serializer.is_valid()): + marks = serializer.save() + print(marks.file) + result_data = fetchResult() + #scan(file) + marks.delete() + return Response(result_data,status=HTTP_202_ACCEPTED) + else: + return Response({"message": "File Missing"},status=HTTP_406_NOT_ACCEPTABLE) + + +def sort_dict(dic): + s_dic = sorted(dic.items(), key=lambda x: x[0].lower()) + new_dic={} + for item in s_dic: + new_dic[item[0]]=item[1] + return new_dic + + + + +def fetchResult(): + sem_result = { + "sem":1, + "credits_earned":20, + "cgpa":10, + "courses":[ + { + "couse_code":"DJ19FEC101", + "course_name":"MATHS-1", + "marks":99, + "pointer":10, + "credits_earned":4, + "grade":"O" + }, + { + + "couse_code":"DJ19FET101", + "course_name":"MATHS-1-tut", + "marks":25, + "pointer":10, + "credits_earned":1, + "grade":"O" + }, + { + "couse_code":"DJ19FEC102", + "course_name":"Phy-1", + "marks":91, + "pointer":10, + "credits_earned":2, + "grade":"O" + }, + { + + "couse_code":"DJ19FEL102", + "course_name":"Phy-1-Tut&Lab", + "marks":25, + "pointer":10, + "credits_earned":1.5, + "grade":"O" + }, + { + "couse_code":"DJ19FEC103", + "course_name":"Chem-1", + "marks":95, + "pointer":10, + "credits_earned":2, + "grade":"O" + }, + { + + "couse_code":"DJ19FEL103", + "course_name":"Chem-1-Tut&Lab", + "marks":25, + "pointer":10, + "credits_earned":1.5, + "grade":"O" + }, + { + "couse_code":"DJ19FEC104", + "course_name":"Mech", + "marks":96, + "pointer":10, + "credits_earned":3, + "grade":"O" + }, + { + + "couse_code":"DJ19FEL104", + "course_name":"Mech-Lab", + "marks":25, + "pointer":10, + "credits_earned":1, + "grade":"O" + }, + { + "couse_code":"DJ19FEC105", + "course_name":"BEE", + "marks":97, + "pointer":10, + "credits_earned":3, + "grade":"O" + }, + { + + "couse_code":"DJ19FEL105", + "course_name":"BEE-Lab", + "marks":25, + "pointer":10, + "credits_earned":1, + "grade":"O" + } + + ] + + } + + return sem_result + + + +''' +Json formate +result_Json ={ + sem_number:{ + "sem":"", + "credits_earned":"", + "cgpa":"", + "courses":[ + { + "couse_code":"", + "course_name":"", + "marks":"", + "pointer":"", + "credits_earned":"", + "grade":"", + } + ] + + }, + ... +} +''' diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..184adf7 --- /dev/null +++ b/backend/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', 'Transcripts.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/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6cdfcc5 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,53 @@ +appdirs==1.4.4 +asgiref==3.3.1 +black==20.8b1 +certifi==2020.12.5 +cffi==1.14.4 +chardet==4.0.0 +click==7.1.2 +coreapi==2.3.3 +coreschema==0.0.4 +cryptography==3.4.1 +defusedxml==0.7.0rc2 +Django==3.1.5 +django-cors-headers==3.7.0 +django-jsonfield==1.4.1 +django-templated-mail==1.1.1 +djangorestframework==3.12.2 +djangorestframework-simplejwt==4.6.0 +djoser==2.1.0 +drf-yasg==1.20.0 +idna==2.10 +inflection==0.5.1 +itypes==1.2.0 +Jinja2==2.11.3 +MarkupSafe==1.1.1 +mypy-extensions==0.4.3 +oauthlib==3.1.0 +openapi-codec==1.3.2 +packaging==20.9 +pathspec==0.8.1 +Pillow==8.1.1 +pycparser==2.20 +PyJWT==2.0.1 +pyparsing==2.4.7 +python-decouple==3.4 +python3-openid==3.2.0 +pytz==2020.5 +regex==2020.11.13 +requests==2.25.1 +requests-oauthlib==1.3.0 +ruamel.yaml==0.16.12 +ruamel.yaml.clib==0.2.2 +semantic-version==2.8.5 +setuptools-rust==0.11.6 +simplejson==3.17.2 +six==1.15.0 +social-auth-app-django==4.0.0 +social-auth-core==4.0.3 +sqlparse==0.4.1 +toml==0.10.2 +typed-ast==1.4.2 +typing-extensions==3.7.4.3 +uritemplate==3.0.1 +urllib3==1.26.3 \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..1124509 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.8.5 \ No newline at end of file