diff --git a/Pipfile b/Pipfile index 6e5a135..c235739 100644 --- a/Pipfile +++ b/Pipfile @@ -10,6 +10,7 @@ python-dotenv = "*" djoser = "*" pillow = "*" whitenoise = "*" +djangochannelsrestframework = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index af0eb49..b67ebf8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7a186c2a779282b0072d0634e264e5e70842a4c9676dfc4d1e18e564860e87c8" + "sha256": "231f11224ec1ef1ad1406ac5644ebcdfac020af9478f407d577553bb05c637df" }, "pipfile-spec": 6, "requires": { @@ -101,6 +101,14 @@ ], "version": "==1.15.1" }, + "channels": { + "hashes": [ + "sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420", + "sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.0" + }, "charset-normalizer": { "hashes": [ "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", @@ -230,6 +238,14 @@ ], "version": "==1.1.1" }, + "djangochannelsrestframework": { + "hashes": [ + "sha256:937260996b78fad66ddf4aa03dc61434b81b21a757897a899cd541d0f197c4ce", + "sha256:ca37fb96bb2f746129972a81dafed42d9785a37a2db36827dbf17848a0a9df96" + ], + "index": "pypi", + "version": "==1.1.0" + }, "djangorestframework": { "hashes": [ "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", diff --git a/stude/accounts/admin.py b/stude/accounts/admin.py index b071683..2ce5065 100644 --- a/stude/accounts/admin.py +++ b/stude/accounts/admin.py @@ -1,10 +1,29 @@ +from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import CustomUser +from year_levels.models import Year_Level +from semesters.models import Semester +from courses.models import Course + + +class CustomUserForm(forms.ModelForm): + year_level = forms.ModelChoiceField( + queryset=Year_Level.objects.all(), required=False) + semester = forms.ModelChoiceField( + queryset=Semester.objects.all(), required=False) + course = forms.ModelChoiceField( + queryset=Course.objects.all(), required=False) + avatar = forms.ImageField(required=False) + + class Meta: + model = CustomUser + fields = '__all__' class CustomUserAdmin(UserAdmin): model = CustomUser + form = CustomUserForm fieldsets = UserAdmin.fieldsets + ( (None, {'fields': ('student_id_number', diff --git a/stude/accounts/migrations/0004_alter_customuser_course_alter_customuser_semester_and_more.py b/stude/accounts/migrations/0004_alter_customuser_course_alter_customuser_semester_and_more.py new file mode 100644 index 0000000..0527dd0 --- /dev/null +++ b/stude/accounts/migrations/0004_alter_customuser_course_alter_customuser_semester_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.2 on 2023-07-04 10:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('semesters', '0001_initial'), + ('courses', '0002_initial'), + ('year_levels', '0001_initial'), + ('accounts', '0003_customuser_subjects'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='course', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='courses.course'), + ), + migrations.AlterField( + model_name='customuser', + name='semester', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='semesters.semester'), + ), + migrations.AlterField( + model_name='customuser', + name='year_level', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='year_levels.year_level'), + ), + ] diff --git a/stude/accounts/migrations/0005_alter_customuser_is_active.py b/stude/accounts/migrations/0005_alter_customuser_is_active.py new file mode 100644 index 0000000..f6ebd78 --- /dev/null +++ b/stude/accounts/migrations/0005_alter_customuser_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2023-07-04 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_alter_customuser_course_alter_customuser_semester_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='is_active', + field=models.BooleanField(default=False), + ), + ] diff --git a/stude/accounts/models.py b/stude/accounts/models.py index 24adb8e..4f33659 100644 --- a/stude/accounts/models.py +++ b/stude/accounts/models.py @@ -33,6 +33,7 @@ class CustomUser(AbstractUser): # Username inherited from base user class # Password inherited from base user class # is_admin inherited from base user class + is_active = models.BooleanField(default=False) is_student = models.BooleanField(default=True) is_studying = models.BooleanField(default=False) is_banned = models.BooleanField(default=False) @@ -41,17 +42,17 @@ class CustomUser(AbstractUser): avatar = models.ImageField(upload_to=_get_upload_to, null=True) course = models.ForeignKey( Course, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, null=True ) year_level = models.ForeignKey( Year_Level, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, null=True ) semester = models.ForeignKey( Semester, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, null=True ) subjects = models.ManyToManyField( @@ -72,5 +73,10 @@ def create_superuser(sender, **kwargs): password = os.getenv('DJANGO_ADMIN_PASSWORD') if not User.objects.filter(username=username).exists(): - User.objects.create_superuser( - username, email, password) + # Create the superuser with is_active set to False + superuser = User.objects.create_superuser( + username=username, email=email, password=password) + + # Activate the superuser + superuser.is_active = True + superuser.save() diff --git a/stude/accounts/serializers.py b/stude/accounts/serializers.py index 79b3298..90f3b54 100644 --- a/stude/accounts/serializers.py +++ b/stude/accounts/serializers.py @@ -1,34 +1,66 @@ from djoser.serializers import UserCreateSerializer as BaseUserRegistrationSerializer from djoser.serializers import UserSerializer as BaseUserSerializer +from django.core import exceptions as django_exceptions +from rest_framework import exceptions as drf_exceptions from rest_framework import serializers from accounts.models import CustomUser from student_status.serializers import StudentStatusSerializer from student_status.models import StudentStatus +from rest_framework.settings import api_settings +from django.contrib.auth.password_validation import validate_password +from courses.models import Course +from year_levels.models import Year_Level +from semesters.models import Semester class CustomUserSerializer(BaseUserSerializer): user_status = StudentStatusSerializer( source='studentstatus', read_only=True) + course = serializers.SlugRelatedField( + many=False, slug_field='name', queryset=Course.objects.all(), required=False, allow_null=True) + year_level = serializers.SlugRelatedField( + many=False, slug_field='name', queryset=Year_Level.objects.all(), required=False, allow_null=True) + semester = serializers.SlugRelatedField( + many=False, slug_field='name', queryset=Semester.objects.all(), required=False, allow_null=True) class Meta(BaseUserSerializer.Meta): model = CustomUser - fields = ('username', 'email', 'password', + fields = ('username', 'email', 'student_id_number', 'year_level', 'semester', 'course', 'subjects', 'avatar', 'first_name', 'last_name', 'is_banned', 'user_status') + read_only_fields = ('is_banned', 'user_status') + +# The model from your custom user -class UserRegistrationSerializer(BaseUserRegistrationSerializer): - class Meta(BaseUserRegistrationSerializer.Meta): - fields = ('username', 'email', 'password', - 'student_id_number', 'year_level', 'semester', 'course', 'subjects', 'avatar', 'first_name', 'last_name') +class UserRegistrationSerializer(serializers.ModelSerializer): + email = serializers.EmailField(required=True) + student_id_number = serializers.CharField(required=True) + password = serializers.CharField(write_only=True) + + class Meta: + model = CustomUser # Use your custom user model here + fields = ('username', 'email', 'password', 'student_id_number', + 'year_level', 'semester', 'course', 'subjects', 'avatar', + 'first_name', 'last_name') + + def validate(self, attrs): + user = self.Meta.model(**attrs) + password = attrs.get("password") + try: + validate_password(password, user) + except django_exceptions.ValidationError as e: + serializer_error = serializers.as_serializer_error(e) + raise serializers.ValidationError( + {"password": serializer_error[api_settings.NON_FIELD_ERRORS_KEY]} + ) + + return super().validate(attrs) def create(self, validated_data): - # Get the user's year_level and semester from the user model instance user = self.Meta.model(**validated_data) + user.set_password(validated_data['password']) + user.save() - # Create a new user using the base serializer's create() method - user = super().create(validated_data) - - # Create a student_status object for the user StudentStatus.objects.create( user=user, active=False, @@ -36,5 +68,4 @@ class UserRegistrationSerializer(BaseUserRegistrationSerializer): y=None, subject=None ) - return user diff --git a/stude/accounts/urls.py b/stude/accounts/urls.py index 8df3791..22c121e 100644 --- a/stude/accounts/urls.py +++ b/stude/accounts/urls.py @@ -3,5 +3,5 @@ from django.urls import path, include urlpatterns = [ path('', include('djoser.urls')), - path('', include('djoser.urls.authtoken')), + path('', include('djoser.urls.jwt')), ] diff --git a/stude/api/routing.py b/stude/api/routing.py new file mode 100644 index 0000000..2ecbbbd --- /dev/null +++ b/stude/api/routing.py @@ -0,0 +1,8 @@ +from django.urls import re_path +from channels.routing import URLRouter +import student_status.routing +import student_status.consumers +websocket_urlpatterns = [ + re_path(r'student_status/', + URLRouter(student_status.routing.websocket_urlpatterns)) +] diff --git a/stude/config/asgi.py b/stude/config/asgi.py index 1b71627..41094d5 100644 --- a/stude/config/asgi.py +++ b/stude/config/asgi.py @@ -1,16 +1,22 @@ -""" -ASGI config for stude 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/4.2/howto/deployment/asgi/ -""" - import os +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack from django.core.asgi import get_asgi_application +import api.routing +from django.urls import re_path os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +# Initialize Django ASGI application early to ensure the AppRegistry +# is populated before importing code that may import ORM models. +django_asgi_app = get_asgi_application() -application = get_asgi_application() +application = ProtocolTypeRouter({ + "http": django_asgi_app, + # Just HTTP for now. (We can add other protocols later.) + 'websocket': AuthMiddlewareStack( + URLRouter( + [re_path(r'ws/', URLRouter(api.routing.websocket_urlpatterns))] + ) + ), +}) diff --git a/stude/config/email.py b/stude/config/email.py new file mode 100644 index 0000000..b7396aa --- /dev/null +++ b/stude/config/email.py @@ -0,0 +1,5 @@ +from djoser import email + + +class ActivationEmail(email.ActivationEmail): + template_name = 'email_activation/email_activation.html' diff --git a/stude/config/settings.py b/stude/config/settings.py index 4a89979..e519d94 100644 --- a/stude/config/settings.py +++ b/stude/config/settings.py @@ -29,13 +29,14 @@ SECRET_KEY = str(os.getenv('SECRET_KEY')) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] +ALLOWED_HOSTS = ['*', '127.0.0.1', 'localhost', '10.0.10.32', '10.0.10.8'] # Email credentials EMAIL_HOST = '' EMAIL_HOST_USER = '' EMAIL_HOST_PASSWORD = '' EMAIL_PORT = '' +EMAIL_USE_TLS = False if (DEBUG == True): EMAIL_HOST = str(os.getenv('DEV_EMAIL_HOST')) @@ -47,11 +48,13 @@ else: EMAIL_HOST_USER = str(os.getenv('PROD_EMAIL_HOST_USER')) EMAIL_HOST_PASSWORD = str(os.getenv('PROD_EMAIL_HOST_PASSWORD')) EMAIL_PORT = str(os.getenv('PROD_EMAIL_PORT')) + EMAIL_USE_TLS = str(os.getenv('PROD_EMAIL_TLS')) # Application definition INSTALLED_APPS = [ + 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -59,8 +62,8 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'rest_framework_simplejwt', 'djoser', - 'rest_framework.authtoken', 'accounts', 'student_status', 'courses', @@ -88,10 +91,14 @@ STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" ROOT_URLCONF = 'config.urls' +ASGI_APPLICATION = "config.asgi.application" + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + BASE_DIR / 'templates', + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -106,7 +113,7 @@ TEMPLATES = [ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', ) } @@ -128,10 +135,14 @@ AUTH_USER_MODEL = 'accounts.CustomUser' DJOSER = { 'SEND_ACTIVATION_EMAIL': True, 'SEND_CONFIRMATION_EMAIL': True, + 'EMAIL': { + 'activation': 'config.email.ActivationEmail' + }, 'ACTIVATION_URL': 'activation/{uid}/{token}', 'USER_AUTHENTICATION_RULES': ['djoser.authentication.TokenAuthenticationRule'], 'SERIALIZERS': { 'user': 'accounts.serializers.CustomUserSerializer', + 'current_user': 'accounts.serializers.CustomUserSerializer', 'user_create': 'accounts.serializers.UserRegistrationSerializer', }, } @@ -179,3 +190,12 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +DOMAIN = '' +if (DEBUG): + DOMAIN = 'exp' +else: + DOMAIN = 'stude' + + +SITE_NAME = 'Stud-E' diff --git a/stude/manage.py b/stude/manage.py index 8e7ac79..e0c0fbe 100644 --- a/stude/manage.py +++ b/stude/manage.py @@ -2,6 +2,7 @@ """Django's command-line utility for administrative tasks.""" import os import sys +from django.core.management.commands.runserver import Command as runserver def main(): @@ -19,4 +20,6 @@ def main(): if __name__ == '__main__': + # Override default port for `runserver` command + runserver.default_addr = '0.0.0.0' main() diff --git a/stude/student_status/consumers.py b/stude/student_status/consumers.py new file mode 100644 index 0000000..5d8a479 --- /dev/null +++ b/stude/student_status/consumers.py @@ -0,0 +1,50 @@ +# consumers.py +import json +from .models import StudentStatus +from .serializers import StudentStatusSerializer +from djangochannelsrestframework.generics import GenericAsyncAPIConsumer +from djangochannelsrestframework.decorators import action +from djangochannelsrestframework.observer import model_observer, observer +from channels.db import database_sync_to_async +import asyncio +from djangochannelsrestframework.mixins import ( + ListModelMixin, + RetrieveModelMixin, +) +from djangochannelsrestframework.permissions import IsAuthenticated + + +class StudentStatusConsumer( + ListModelMixin, + RetrieveModelMixin, + GenericAsyncAPIConsumer, +): + permission_classes = [IsAuthenticated] + queryset = StudentStatus.objects.filter(active=True) + serializer_class = StudentStatusSerializer + + async def websocket_connect(self, message): + # This method is called when the websocket is handshaking as part of the connection process. + await self.accept() + self.send_updates_task = asyncio.create_task(self.send_updates()) + + async def websocket_disconnect(self, message): + # This method is called when the WebSocket closes for any reason. + # Here we want to cancel our periodic task that sends updates + self.send_updates_task.cancel() + + @database_sync_to_async + def get_student_statuses(self): + queryset = self.get_queryset() + return StudentStatusSerializer(queryset, many=True).data + + async def send_updates(self): + while True: + try: + data = await self.get_student_statuses() + # print(f"Sending update: {data}") Debug + await self.send(text_data=json.dumps(data)) + await asyncio.sleep(5) + except Exception as e: + print(f"Exception in send_updates: {e}") + break # Break the loop on error diff --git a/stude/student_status/migrations/0005_alter_studentstatus_subject.py b/stude/student_status/migrations/0005_alter_studentstatus_subject.py new file mode 100644 index 0000000..845a322 --- /dev/null +++ b/stude/student_status/migrations/0005_alter_studentstatus_subject.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.2 on 2023-07-04 10:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('subjects', '0002_subjectstudent_subject_students'), + ('student_status', '0004_alter_studentstatus_study_group'), + ] + + operations = [ + migrations.AlterField( + model_name='studentstatus', + name='subject', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='subjects.subject'), + ), + ] diff --git a/stude/student_status/models.py b/stude/student_status/models.py index b2ca891..2787206 100644 --- a/stude/student_status/models.py +++ b/stude/student_status/models.py @@ -11,7 +11,7 @@ class StudentStatus(models.Model): x = models.FloatField(null=True) y = models.FloatField(null=True) subject = models.ForeignKey( - 'subjects.Subject', on_delete=models.CASCADE, null=True) + 'subjects.Subject', on_delete=models.SET_NULL, null=True) active = models.BooleanField(default=False) timestamp = models.DateField(auto_now_add=True) study_group = models.ManyToManyField( diff --git a/stude/student_status/routing.py b/stude/student_status/routing.py new file mode 100644 index 0000000..34c10f4 --- /dev/null +++ b/stude/student_status/routing.py @@ -0,0 +1,7 @@ +# routing.py +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r"active", consumers.StudentStatusConsumer.as_asgi()), +] diff --git a/stude/student_status/serializers.py b/stude/student_status/serializers.py index 26768ad..4531a88 100644 --- a/stude/student_status/serializers.py +++ b/stude/student_status/serializers.py @@ -3,9 +3,11 @@ from .models import StudentStatus class StudentStatusSerializer(serializers.ModelSerializer): - year_level = serializers.CharField(source='user.year_level', read_only=True) + year_level = serializers.CharField( + source='user.year_level', read_only=True) course = serializers.CharField(source='user.course', read_only=True) semester = serializers.CharField(source='user.semester', read_only=True) + user = serializers.CharField(source='user.full_name', read_only=True) class Meta: model = StudentStatus @@ -17,7 +19,7 @@ class StudentStatusSerializer(serializers.ModelSerializer): student_status = StudentStatus.objects.create( user=user, defaults=validated_data) return student_status - + def update(self, instance, validated_data): active = validated_data.get('active', None) @@ -26,4 +28,4 @@ class StudentStatusSerializer(serializers.ModelSerializer): validated_data['y'] = None validated_data['subject'] = None - return super().update(instance, validated_data) \ No newline at end of file + return super().update(instance, validated_data) diff --git a/stude/student_status/urls.py b/stude/student_status/urls.py index 963e0cd..45c6b5c 100644 --- a/stude/student_status/urls.py +++ b/stude/student_status/urls.py @@ -3,5 +3,5 @@ from .views import StudentStatusAPIView, ActiveStudentStatusListAPIView urlpatterns = [ path('self/', StudentStatusAPIView.as_view()), - path('active_list/', ActiveStudentStatusListAPIView.as_view()), + path('list/', ActiveStudentStatusListAPIView.as_view()), ] diff --git a/stude/study_groups/serializers.py b/stude/study_groups/serializers.py index bb3b0fe..5c1ec6e 100644 --- a/stude/study_groups/serializers.py +++ b/stude/study_groups/serializers.py @@ -1,12 +1,14 @@ from rest_framework import serializers from .models import StudyGroup, StudyGroupMembership from accounts.models import CustomUser +from subjects.models import Subject class StudyGroupSerializer(serializers.ModelSerializer): users = serializers.SlugRelatedField( - queryset=CustomUser.objects.all(), many=True, slug_field='name', allow_null=True) - subject = serializers.CharField(source='subject.Subject', read_only=True) + queryset=CustomUser.objects.all(), many=True, slug_field='name', required=False, allow_null=True) + subject = serializers.SlugRelatedField( + many=False, slug_field='name', queryset=Subject.objects.all(), required=True, allow_null=False) class Meta: model = StudyGroup diff --git a/stude/studygroup_messages/serializers.py b/stude/studygroup_messages/serializers.py index 5222e89..89b11da 100644 --- a/stude/studygroup_messages/serializers.py +++ b/stude/studygroup_messages/serializers.py @@ -1,13 +1,14 @@ from rest_framework import serializers from .models import Message from accounts.models import CustomUser +from study_groups.models import StudyGroup class MessageSerializer(serializers.ModelSerializer): user = serializers.SlugRelatedField( - queryset=CustomUser.objects.all(), slug_field='full_name', required=False) - study_group = serializers.CharField( - source='subject.Subject', read_only=True, required=False) + queryset=CustomUser.objects.all(), slug_field='full_name', required=True) + study_group = serializers.SlugRelatedField( + queryset=StudyGroup.objects.all(), slug_field='name', required=True) class Meta: model = Message diff --git a/stude/templates/email_activation/email_activation.html b/stude/templates/email_activation/email_activation.html new file mode 100644 index 0000000..03321f8 --- /dev/null +++ b/stude/templates/email_activation/email_activation.html @@ -0,0 +1,26 @@ +{% load i18n %} + +{% block subject %} +{% blocktrans %}Account activation on {{ site_name }}{% endblocktrans %} +{% endblock subject %} + +{% block text_body %} +{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %} + +{% trans "Please open the following link to activate your account in-app:" %} +{{ domain }}://{{ url|safe }} + +{% trans "Thanks you for using StudE!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endblock text_body %} + +{% block html_body %} +

{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %}

+ +

{% trans "Please go to the following page to activate your account in-app:" %}

+

{{ domain }}://--/{{ url|safe }}

+ +

{% blocktrans %}Many thanks from the {{ site_name }} team{% endblocktrans %}

+ +{% endblock html_body %} \ No newline at end of file diff --git a/stude/year_levels/models.py b/stude/year_levels/models.py index 02ddafb..c67c7d1 100644 --- a/stude/year_levels/models.py +++ b/stude/year_levels/models.py @@ -24,6 +24,6 @@ def populate_courses(sender, **kwargs): name='3rd Year', shortname='3rdYr') Year_Level.objects.get_or_create( name='4th Year', shortname='4thYr') - Year_Level.objects.get_or_create( - name='Irregular', shortname='Irreg') + # Year_Level.objects.get_or_create( + # name='Irregular', shortname='Irreg') # Add more predefined records as needed