diff --git a/.gitignore b/.gitignore index 7b8a34cc..427d9337 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,8 @@ website/db.sqlit .env .venv env/ +myEnv/ +myenv/ venv/ ENV/ env.bak/ @@ -83,4 +85,7 @@ website/events_calendar/migrations/ website/migrations/ # Media files -media/ \ No newline at end of file +media/ + +staticfiles/ +.agent/ \ No newline at end of file diff --git a/README.md b/README.md index 59a74e66..44bcec80 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,6 @@ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET='googleSECRET' 4. run ```python manage.py makemigrations``` 5. run ```python manage.py migrate``` 6. run ```python manage.py runserver``` + +POSTMAN API +COLLECTION: [REC DRF](https://postman.com/rec-drf-backend-00/workspace/recursion-drf-port-backend/api/3ccf67a5-e059-4d31-8d22-8f3302f94876/version/b021621d-8a18-4f86-aede-8e2737156e9c) \ No newline at end of file diff --git a/old_requirements.txt b/old_requirements.txt new file mode 100644 index 00000000..375f1a70 Binary files /dev/null and b/old_requirements.txt differ diff --git a/requirements.txt b/requirements.txt index 9aa1660d..e25aaf98 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/website/.env.example b/website/.env.example deleted file mode 100644 index 34fd7063..00000000 --- a/website/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -DB_NAME=HELLO_DJANGO -DB_USER=U_HELLO -DB_PASSWORD=hA8(scA@!fg3*sc&xaGh&6%-l<._&xCf -DB_HOST=127.0.0.1 -DB_PORT="" -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY="" -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET="" \ No newline at end of file diff --git a/website/blog/templates/blog/blog_list.html b/website/blog/templates/blog/blog_list.html index 91c17084..4e7ca7d3 100644 --- a/website/blog/templates/blog/blog_list.html +++ b/website/blog/templates/blog/blog_list.html @@ -1,4 +1,6 @@ +{% load static %} {% load blog_tags %} + {% for post in posts %}
  • -
    -{% endfor %} \ No newline at end of file +
    +{% endfor %} diff --git a/website/blog/views.py b/website/blog/views.py index 26477dd4..0ac1bfe1 100644 --- a/website/blog/views.py +++ b/website/blog/views.py @@ -1,4 +1,6 @@ from django.shortcuts import render, redirect,get_object_or_404, get_list_or_404 +from utils.content_filter import check_content_safety +from django_ratelimit.decorators import ratelimit from .models import * from user_profile.models import * from events.models import * @@ -78,6 +80,7 @@ def valid_url_extension(url, extension_list=VALID_IMAGE_EXTENSIONS): return type @login_required +@ratelimit(key='user', rate='1/10m', method='POST', block=True) def add_blog(request): form = Postform(request.POST or None) @@ -85,6 +88,11 @@ def add_blog(request): if request.method=='POST': form2 = Tagform(request.POST) if form.is_valid() and form2.is_valid(): + description = form.cleaned_data.get('description') + title = form.cleaned_data.get('title') + if not check_content_safety(description, title): + form.add_error('description', "Your post contains irrelevant/inappropriate content and cannot be published.") + return render(request, 'blog/blog_form.html', {'form': form,'form2':form2,}) f = form.save(commit=False) f.user_id = request.user form.save() @@ -112,7 +120,7 @@ def add_blog(request): 'domain': current_site.domain, 'post' : Posts.objects.get(pk=f.id), }) - msg=(subject, message, 'webmaster@localhost', [user.email]) + msg=(subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) result = send_mass_mail(messages, fail_silently=False) return redirect('blog:list_blogs') @@ -263,7 +271,7 @@ def update_blogs(request, id): 'domain': current_site.domain, 'post': post, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -324,7 +332,7 @@ def add_reply(request, id): 'domain': current_site.domain, 'post': post, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -386,7 +394,7 @@ def update_reply(request, id): 'domain': current_site.domain, 'post': post, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -436,7 +444,7 @@ def add_comment(request, id): 'domain': current_site.domain, 'post': post, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -488,7 +496,7 @@ def update_comment(request, id): 'domain': current_site.domain, 'post': post, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -750,7 +758,7 @@ def add_comment_reply(request, id): 'domain': current_site.domain, 'post': reply.post_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user @@ -801,7 +809,7 @@ def update_comment_reply(request, id): 'domain': current_site.domain, 'post': reply.post_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) '''for follow in follows: user = follow.user diff --git a/website/events_calendar/api/__init__.py b/website/events_calendar/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/events_calendar/api/filters.py b/website/events_calendar/api/filters.py new file mode 100644 index 00000000..179d4fbc --- /dev/null +++ b/website/events_calendar/api/filters.py @@ -0,0 +1,14 @@ +from django_filters import rest_framework as filters +from events_calendar.models import Events_Calendar + + +class EventsFilter(filters.FilterSet): + event_type = filters.ChoiceFilter(field_name='event_type', choices=Events_Calendar.event_choices) + target_year = filters.ChoiceFilter(field_name='target_year', choices=Events_Calendar.year_choices) + + class Meta: + model = Events_Calendar + fields = { + 'start_time': ['gte', 'lte'], + 'end_time': ['gte', 'lte'], + } diff --git a/website/events_calendar/api/permissions.py b/website/events_calendar/api/permissions.py new file mode 100644 index 00000000..ee110249 --- /dev/null +++ b/website/events_calendar/api/permissions.py @@ -0,0 +1,22 @@ +from rest_framework.permissions import IsAuthenticatedOrReadOnly, BasePermission, SAFE_METHODS + + +class EventsListCreatePermission(BasePermission): + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + if not request.user.is_authenticated: + return False + role = request.user.profile.role + return role in ('1', '2') + + +class EventRetrieveUpdateDestroyPermission(IsAuthenticatedOrReadOnly): + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + if not bool(request.user and request.user.is_authenticated): + return False + role = request.user.profile.role + return role in ('1', '2') + diff --git a/website/events_calendar/api/serializers.py b/website/events_calendar/api/serializers.py new file mode 100644 index 00000000..2920f184 --- /dev/null +++ b/website/events_calendar/api/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from events_calendar.models import Events_Calendar +from user_profile.api.serializers import UserSerializer + + +class EventsSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='events_api:events_detail', lookup_field='id', + lookup_url_kwarg='slug') + user = UserSerializer(read_only=True) + + class Meta: + model = Events_Calendar + # exclude = ['id', ] + fields = '__all__' + read_only_fields = ['user', 'url', 'duration', ] diff --git a/website/events_calendar/api/urls.py b/website/events_calendar/api/urls.py new file mode 100644 index 00000000..49fab142 --- /dev/null +++ b/website/events_calendar/api/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from .views import ( + EventsListCreateView, + EventsRetrieveUpdateDestroyView, + +) + +app_name = 'events_api' + +urlpatterns = [ + path('', EventsListCreateView.as_view(), name='events_list'), + path('/', EventsRetrieveUpdateDestroyView.as_view(), name='events_detail'), +] diff --git a/website/events_calendar/api/views.py b/website/events_calendar/api/views.py new file mode 100644 index 00000000..667ad07d --- /dev/null +++ b/website/events_calendar/api/views.py @@ -0,0 +1,45 @@ +from django_filters import rest_framework as filters + +from rest_framework.filters import SearchFilter, OrderingFilter +from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from events_calendar.models import Events_Calendar +from events_calendar.views import get_event_duration +from .serializers import EventsSerializer +from .permissions import EventsListCreatePermission, EventRetrieveUpdateDestroyPermission +from .filters import EventsFilter + + +# HIGH POTENTIAL FOR CACHING + +class EventsListCreateView(ListCreateAPIView): + serializer_class = EventsSerializer + permission_classes = (EventsListCreatePermission,) + filter_backends = (filters.DjangoFilterBackend, SearchFilter, OrderingFilter) + filterset_class = EventsFilter + search_fields = ['title', 'description', 'venue'] + ordering_fields = ['start_time', 'end_time', 'updated_at', 'duration'] + + def get_queryset(self): + return Events_Calendar.objects.all().order_by('-start_time') + + def perform_create(self, serializer): + start_time, end_time = serializer.validated_data.get('start_time'), serializer.validated_data.get('end_time') + serializer.save(user=self.request.user, duration=get_event_duration(start_time, end_time)) + + +class EventsRetrieveUpdateDestroyView(RetrieveUpdateDestroyAPIView): + serializer_class = EventsSerializer + permission_classes = (EventRetrieveUpdateDestroyPermission,) + lookup_field = 'id' + lookup_url_kwarg = 'slug' + + def get_queryset(self): + return Events_Calendar.objects.select_related('user').all() + + def perform_update(self, serializer): + # Saving twice. Need to find a better way to do it + # Preferably using pre_save signals + event = serializer.save() + event.duration = get_event_duration(event.start_time, event.end_time) + event.save() diff --git a/website/events_calendar/models.py b/website/events_calendar/models.py index d62cf242..af90a6ee 100644 --- a/website/events_calendar/models.py +++ b/website/events_calendar/models.py @@ -1,13 +1,16 @@ from django.db import models from django.contrib.auth.models import User +from django.urls import reverse from markdownx.models import MarkdownxField from markdownx.utils import markdownify import os -def content_file_name(instance,filename): - ext="png" - filename= str(instance.title)+"."+str(ext) - return os.path.join('images/',filename) + +def content_file_name(instance, filename): + ext = "png" + filename = str(instance.title) + "." + str(ext) + return os.path.join('images/', filename) + class Events_Calendar(models.Model): title = models.CharField(max_length=30) @@ -24,15 +27,15 @@ class Events_Calendar(models.Model): ('NIT Durgapur', 'NIT Durgapur'), ('Global Participants', 'Global Participants'), ) - event_type = models.CharField(max_length=20, choices=event_choices ,default='Class') - target_year = models.CharField(max_length=40, choices=year_choices ,default='First Year') - description = MarkdownxField(null=True,blank=True) + event_type = models.CharField(max_length=20, choices=event_choices, default='Class') + target_year = models.CharField(max_length=40, choices=year_choices, default='First Year') + description = MarkdownxField(null=True, blank=True) image = models.ImageField(blank=True, null=True, upload_to=content_file_name) - link = models.URLField(null=True,blank=True) + link = models.URLField(null=True, blank=True) start_time = models.DateTimeField() end_time = models.DateTimeField() duration = models.CharField(max_length=20, null=True, blank=True) - venue = models.CharField(max_length=20,default="Online",null=True) + venue = models.CharField(max_length=20, default="Online", null=True) created_at = models.DateTimeField(auto_now=False, auto_now_add=True) updated_at = models.DateTimeField(auto_now=True, auto_now_add=False) @@ -40,9 +43,13 @@ class Events_Calendar(models.Model): @property def formatted_markdown(self): return markdownify(self.description) - + def __str__(self): return self.event_type + " - " + self.title + + def get_absolute_url(self): + return reverse('events_api:event_detail', kwargs={'id': self.id}) + class Meta: managed = True db_table = 'events_calendar' diff --git a/website/forum/api/__init__.py b/website/forum/api/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/website/forum/api/views.py b/website/forum/api/views.py new file mode 100755 index 00000000..6387a44b --- /dev/null +++ b/website/forum/api/views.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import User +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.decorators import api_view, throttle_classes +from rest_framework.throttling import AnonRateThrottle +from rest_framework.response import Response + +from django.utils import timezone +import datetime +from events_calendar.api.serializers import EventsSerializer, Events_Calendar +# from events_calendar.models import Events_Calendar +from user_profile.models import User, Profile +from user_profile.api.serializers import ProfileSerializer, UserSerializer + + +@api_view(['GET', ]) +def home_view(request) -> Response: + data = {} + user = request.user + max_events = 4 + if user.is_authenticated: + data['user_profile'] = ProfileSerializer(Profile.objects.get(user=user), + context={'request': request}).data + else: + data['user_profile'] = {} + + today = timezone.now() + founding_date = datetime.datetime(2014, 9, 1, 00, 00, tzinfo=today.tzinfo) + data['years_of_experience'] = (today - founding_date).days // 365 # roughly + upcoming_events = Events_Calendar.objects.filter( + start_time__range=[today, today + datetime.timedelta(days=7)] + )[:max_events] # expects QS to be sorted by start date + data['upcoming_events'] = EventsSerializer(upcoming_events, many=True, context={'request': request}).data + data['hours_teaching'] = 300 + Events_Calendar.objects.filter(event_type='Class').count() * 2 + data['contest_count'] = 40 + Events_Calendar.objects.filter(event_type='Contest').count() + return Response(data=data) diff --git a/website/forum/static/css/team.css b/website/forum/static/css/team.css index 9e837e01..e0088a4a 100644 --- a/website/forum/static/css/team.css +++ b/website/forum/static/css/team.css @@ -27,7 +27,9 @@ .pic img { width: 100%; - height: auto; + aspect-ratio: 1 / 1; + height: 100%; + object-fit: cover; } .our-team .social-links { diff --git a/website/forum/static/image/profile_pic/default.png b/website/forum/static/image/profile_pic/default.png new file mode 100644 index 00000000..77d3ae0d Binary files /dev/null and b/website/forum/static/image/profile_pic/default.png differ diff --git a/website/forum/templates/base.html b/website/forum/templates/base.html index d5e92ce2..42060061 100644 --- a/website/forum/templates/base.html +++ b/website/forum/templates/base.html @@ -165,7 +165,7 @@

    Contacts

    Phones:
    -
    Ankit Maskara: +918420998766 +
    Prathamesh Mandiye: +918240048380
    diff --git a/website/forum/tokens.py b/website/forum/tokens.py index 3fa8b747..90e7faa7 100644 --- a/website/forum/tokens.py +++ b/website/forum/tokens.py @@ -1,5 +1,5 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.utils import six +import six class AccountActivationTokenGenerator(PasswordResetTokenGenerator): def _make_hash_value(self, user, timestamp): diff --git a/website/forum/views.py b/website/forum/views.py index d9d4c92d..d49753ee 100644 --- a/website/forum/views.py +++ b/website/forum/views.py @@ -1,4 +1,6 @@ from django.shortcuts import render, redirect,get_object_or_404, get_list_or_404 +from utils.content_filter import check_content_safety +from django_ratelimit.decorators import ratelimit from .models import * from user_profile.models import * from events.models import * @@ -119,6 +121,7 @@ def valid_url_extension(url, extension_list=VALID_IMAGE_EXTENSIONS): return type @login_required +@ratelimit(key='user', rate='1/10m', method='POST', block=True) def add_question(request): form = Questionform(request.POST or None) @@ -126,6 +129,11 @@ def add_question(request): if request.method=='POST': form2 = Tagform(request.POST) if form.is_valid() and form2.is_valid(): + description = form.cleaned_data.get('description') + title = form.cleaned_data.get('title') + if not check_content_safety(description, title): + form.add_error('description', "Your post contains irrelevant/inappropriate content and cannot be published.") + return render(request, 'forum/questions-form.html', {'form': form,'form2':form2,}) f = form.save(commit=False) f.user_id = request.user form.save() @@ -155,7 +163,7 @@ def add_question(request): 'domain': current_site.domain, 'question' : Questions.objects.get(pk=f.id), }) - msg=(subject, message, 'webmaster@localhost', [user.email]) + msg=(subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) return redirect('forum:list_questions') @@ -312,7 +320,7 @@ def update_questions(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -323,7 +331,7 @@ def update_questions(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -378,7 +386,7 @@ def add_answer(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -389,7 +397,7 @@ def add_answer(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -440,7 +448,7 @@ def update_answer(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -451,7 +459,7 @@ def update_answer(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -518,7 +526,7 @@ def add_comment(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -529,7 +537,7 @@ def add_comment(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -571,7 +579,7 @@ def update_comment(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -582,7 +590,7 @@ def update_comment(request, id): 'domain': current_site.domain, 'question': question, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -715,7 +723,7 @@ def add_comment_answer(request, id): 'domain': current_site.domain, 'question': answer.question_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -726,7 +734,7 @@ def add_comment_answer(request, id): 'domain': current_site.domain, 'question': answer.question_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) # result = send_mass_mail(messages, fail_silently=False) @@ -767,7 +775,7 @@ def update_comment_answer(request, id): 'domain': current_site.domain, 'question': answer.question_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) messages += (msg,) for follow in follows: user = follow.user @@ -778,7 +786,7 @@ def update_comment_answer(request, id): 'domain': current_site.domain, 'question': answer.question_id, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) #result = send_mass_mail(messages, fail_silently=False) diff --git a/website/getting_started/api/serializers.py b/website/getting_started/api/serializers.py new file mode 100644 index 00000000..b4da88f6 --- /dev/null +++ b/website/getting_started/api/serializers.py @@ -0,0 +1,75 @@ +from rest_framework import serializers +from getting_started.models import ( + Level, + Topic, + SubTopic, + Note, + File, + Link, +) + +class SubTopicSerializer(serializers.ModelSerializer): + class Meta: + model = SubTopic + fields = ['id','sub_topic'] + +class TopicSerializer(serializers.ModelSerializer): + subtopic = SubTopicSerializer( + source='subtopic_set', + many=True, + read_only=True + ) + class Meta: + model = Topic + fields = ['Topic_title','subtopic'] + +class LevelSerializer(serializers.ModelSerializer): + topic = TopicSerializer( + source='topic_set', + many=True, + read_only=True + ) + class Meta: + model = Level + fields = '__all__' + +class NoteSerializer(serializers.ModelSerializer): + class Meta: + model = Note + fields = '__all__' + +class LinkSerializer(serializers.ModelSerializer): + class Meta: + model = Link + fields = '__all__' + +class FileSerializer(serializers.ModelSerializer): + link = LinkSerializer( + source='link_set', + many=True, + read_only=True + ) + class Meta: + model = File + fields = '__all__' + + +class SubTopicContentSerializer(serializers.ModelSerializer): + note = NoteSerializer( + source='note_set', + many=True, + read_only=True + ) + file = FileSerializer( + source='file_set', + many=True, + read_only=True + ) + class Meta: + model = SubTopic + fields = "__all__" + + + + + diff --git a/website/getting_started/api/urls.py b/website/getting_started/api/urls.py new file mode 100644 index 00000000..55e04bf0 --- /dev/null +++ b/website/getting_started/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from user_profile.views import activate +from .views import ( + TopicListAPIView, + SubTopicRetrieveAPIView, +) + +app_name = 'getting_started_api' + +urlpatterns = [ + path('contents/', TopicListAPIView.as_view(), name='getting_started'), + path('/', SubTopicRetrieveAPIView.as_view(), name='subtopic_detail'), +] diff --git a/website/getting_started/api/views.py b/website/getting_started/api/views.py new file mode 100644 index 00000000..cfce9302 --- /dev/null +++ b/website/getting_started/api/views.py @@ -0,0 +1,23 @@ +from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView +from getting_started.models import Topic, SubTopic, Level +from .serializers import LevelSerializer , SubTopicContentSerializer + + + +class TopicListAPIView(ListAPIView): + queryset = Level.objects.all() + serializer_class = LevelSerializer + + def get_queryset(self): + level = self.request.query_params.get('level', None) + return super().get_queryset() + + +class SubTopicRetrieveAPIView(RetrieveUpdateAPIView): + queryset = SubTopic.objects.all() + serializer_class = SubTopicContentSerializer + lookup_field = 'id' + lookup_url_kwarg = 'subtopic_id' + + def get_queryset(self): + return super().get_queryset() \ No newline at end of file diff --git a/website/import_scripts/import_users.py b/website/import_scripts/import_users.py index ac782c02..05fa6afe 100644 --- a/website/import_scripts/import_users.py +++ b/website/import_scripts/import_users.py @@ -6,6 +6,7 @@ import csv from random import choice from string import ascii_uppercase +from website.utils import save_local_profile_pic_to_media randhash = ''.join(choice(ascii_uppercase) for i in range(32)) @@ -35,18 +36,25 @@ u.profile.college = row['College'] u.profile.dept = row['Dept'] u.profile.email_confirmed=True - image_url = 'https://recursionnitd.in/'+'static/image/profile_pic/' + str(random.randint(1,15)) + '.png' - full_path = 'media/images/' + username + '.png' + #Development + #For development + # image_url = 'http://127.0.0.1:8000/'+'static/image/profile_pic/' + str(random.randint(1,15)) + '.png' + #Production: copy local static into MEDIA instead of fetching external URL try: - urllib.request.urlretrieve(image_url, full_path) - except: + rel_media = save_local_profile_pic_to_media(username) + except Exception as e: + print("import_users: save_local_profile_pic_to_media exception:", repr(e)) + rel_media = False + + if not rel_media: print("Downloadable Image Not Found!") - u.profile.image = '../' + full_path + else: + u.profile.image = rel_media # relative to MEDIA_ROOT u.save() - - - + + + diff --git a/website/interview_exp/api/__init__.py b/website/interview_exp/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/interview_exp/api/filters.py b/website/interview_exp/api/filters.py new file mode 100644 index 00000000..2d1dca5a --- /dev/null +++ b/website/interview_exp/api/filters.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters +from interview_exp.models import Experiences + + +# need to revise the filters if needed +class ExperiencesFilter(filters.FilterSet): + status = filters.ChoiceFilter(field_name='verification_Status', choices=Experiences.verification_Status_choices) + role_type = filters.ChoiceFilter(field_name='role_Type', choices=Experiences.role_Type_choices) + ctc = filters.RangeFilter(field_name='total_Compensation') + + class Meta: + model = Experiences + fields = {} diff --git a/website/interview_exp/api/permissions.py b/website/interview_exp/api/permissions.py new file mode 100644 index 00000000..919e57e0 --- /dev/null +++ b/website/interview_exp/api/permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import IsAuthenticated, BasePermission + + +class RevisionPermissions(IsAuthenticated): + def has_object_permission(self, request, view, obj): + user = request.user + role = user.profile.role + if role in ('1', '2'): + return True + return obj.user == user diff --git a/website/interview_exp/api/serializers.py b/website/interview_exp/api/serializers.py new file mode 100644 index 00000000..3e5369b6 --- /dev/null +++ b/website/interview_exp/api/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from interview_exp.models import Experiences, Revisions +from user_profile.api.serializers import UserSerializer + + +class IESerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + verifier = UserSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='experiences_api:ie_detail', lookup_field='id', + lookup_url_kwarg='slug') + + class Meta: + model = Experiences + fields = '__all__' + read_only_fields = ['user', 'created_at', 'updated_at', 'verifier'] + + +class RevisionSerializer(serializers.ModelSerializer): + experience = IESerializer(read_only=True) + reviewer = UserSerializer(read_only=True) + + # experience = serializers.HyperlinkedRelatedField(view_name='experiences_api:ie_detail', + # lookup_field='id', + # lookup_url_kwarg='slug', + # read_only=True) + # reviewer = serializers.HyperlinkedRelatedField(view_name='user_profile_api:user_detail', + # lookup_field='username', + # lookup_url_kwarg='username', + # read_only=True) + message = serializers.CharField(required=False) + + class Meta: + model = Revisions + fields = '__all__' + read_only_fields = ['created_at', 'updated_at', 'id', 'reviewer'] diff --git a/website/interview_exp/api/urls.py b/website/interview_exp/api/urls.py new file mode 100644 index 00000000..393ddb4f --- /dev/null +++ b/website/interview_exp/api/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import ( + IEListView, + RetrieveUpdateIEView, + RevisionsListView, + RetrieveUpdateRevisionView, + CreateRevision, +) +from user_profile.views import activate + +app_name = 'experiences_api' + +urlpatterns = [ + path('', IEListView.as_view(), name='ie_list'), + path('/', RetrieveUpdateIEView.as_view(), name='ie_detail'), + path('/revisions//', CreateRevision.as_view(), name='add_revision'), + path('revisions/', RevisionsListView.as_view(), name='revision_list'), + path('revisions//', RetrieveUpdateRevisionView.as_view(), name='revision_detail'), +] diff --git a/website/interview_exp/api/views.py b/website/interview_exp/api/views.py new file mode 100644 index 00000000..08edf7cc --- /dev/null +++ b/website/interview_exp/api/views.py @@ -0,0 +1,270 @@ +from django.contrib.auth.models import User +from django.db.models import Q +from django.contrib.sites.shortcuts import get_current_site +from django.template.loader import render_to_string +from django.core.mail import EmailMessage +from django.shortcuts import get_object_or_404 +from django.conf import settings +from django.core.mail import send_mass_mail +from rest_framework.permissions import IsAdminUser, IsAuthenticated, AllowAny +from rest_framework.decorators import api_view, throttle_classes +from rest_framework.throttling import AnonRateThrottle +from rest_framework.response import Response +from rest_framework.filters import SearchFilter, OrderingFilter +from rest_framework.generics import ( + CreateAPIView, + ListAPIView, + ListCreateAPIView, + RetrieveUpdateAPIView, +) +from rest_framework.serializers import ValidationError +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView +from django_filters import rest_framework as filters +from decouple import config +from .filters import ExperiencesFilter +from interview_exp.models import Experiences, Revisions +from user_profile.models import Profile +from user_profile.utils import ThreadedMailing +from user_profile.utils_permissions import ViewUpdatePermission, IsMemberOrAbove + +from .serializers import ( + IESerializer, + RevisionSerializer +) + +# email only for trial purposes + + +class IEListView(ListCreateAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = IESerializer + filter_backends = (filters.DjangoFilterBackend, SearchFilter, OrderingFilter) + filterset_class = ExperiencesFilter + search_fields = ['company', 'user__username', 'year'] + ordering_fields = ['updated_at', 'total_Compensation', 'year'] + + def get_queryset(self): + qs = Experiences.objects.all() + user = self.request.user + if user.profile.role == '3': + return qs.filter(Q(user=user) | Q(verification_Status='Approved')) + return qs + + def perform_create(self, serializer): + # TODO + # send thank you mail for posting + user = self.request.user + exp = serializer.save(user=user, verification_Status='Review Pending') + + ### mailing part + + # # region old mailing + # profiles = Profile.objects.filter(~Q(role = '3')) + # messages = () + # f=exp + # for profile in profiles: + # user = profile.user + # current_site = get_current_site(self.request) + # subject = 'New Activity in Interview Experiences Section' + # message = render_to_string('new_experience_entry_email.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'experience': Experiences.objects.get(pk=f.id), + # }) + # msg = (subject, message, 'webmaster@localhost', ["example@gmail.com",]) + # if msg not in messages: + # messages += (msg,) + # result = send_mass_mail(messages, fail_silently=False) + # # endregion + + # region new mailing + member_users = User.objects.filter(~Q(profile__role='3')) + domain = get_current_site(self.request).domain + subject = 'New Activity in Interview Experiences Section' + messages = [ + EmailMessage( + subject, + render_to_string( + 'new_experience_entry_email.html', + { + 'user': user, + 'domain': domain, + 'experience': exp, + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, + } + ), + # to=[user.email, ], + to=[user.email, ], + ) + for user in member_users + ] + # uncomment below to mail + threaded_mail = ThreadedMailing(messages, fail_silently=False, verbose=1) + threaded_mail.start() + # endregion + + +class RetrieveUpdateIEView(RetrieveUpdateAPIView): + queryset = Profile.objects.all() + serializer_class = IESerializer + permission_classes = (ViewUpdatePermission,) + lookup_field = 'id' + lookup_url_kwarg = 'slug' + + def get_queryset(self): + user = self.request.user + qs = Experiences.objects.all() + if user.profile.role == '3': + return qs.filter(Q(user=user) | Q(verification_Status='Approved')) + return qs + + def perform_update(self, serializer): + exp = serializer.save() + curr_status = exp.verification_Status + emails = [] + if curr_status == 'Approved': + exp.verification_Status = 'Review Pending' + elif curr_status == 'Changes Requested': + revision = Revisions.objects.filter(experience=exp) + if revision.exists(): + revision = revision.first() + reviewer = revision.reviewer + else: # send the mail to a random member/superuser(exceptional case that revision is not found) + reviewer = Profile.objects.filter(~Q(role='3')).first() + ## mailing part + domain = get_current_site(self.request).domain + subject = 'New Activity in Interview Experiences Section' + messages = [ + EmailMessage( + subject, + render_to_string( + 'update_experience_email.html', + { + 'user': reviewer, + 'domain': domain, + 'experience': exp, + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, + } + ), + to=[reviewer.email, ], # to=[reviewer.email, ] + ) + ] + # uncomment below to mail + threaded_mail = ThreadedMailing(messages, fail_silently=True, verbose=1) + threaded_mail.start() + else: + member_users = User.objects.filter(~Q(profile__role='3')) + domain = get_current_site(self.request).domain + subject = 'New Activity in Interview Experiences Section' + messages = [ + EmailMessage( + subject, + render_to_string( + 'update_Experience_to_all_email.html', + { + 'user': user, + 'domain': domain, + 'experience': exp, + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, + } + ), + to=[user.email, ], # to=[reviewer.email, ] + ) + for user in member_users + ] + # uncomment below to mail + threaded_mail = ThreadedMailing(messages, fail_silently=True, verbose=1) + threaded_mail.start() + exp.save() + + +class RevisionsListView(ListAPIView): + serializer_class = RevisionSerializer + + permission_classes = (IsMemberOrAbove,) + + def get_queryset(self): + qs = Revisions.objects.prefetch_related('experience').prefetch_related('reviewer').all() + return qs + + +class RetrieveUpdateRevisionView(RetrieveUpdateAPIView): + serializer_class = RevisionSerializer + permission_classes = (IsMemberOrAbove,) + lookup_field = 'experience_id' + lookup_url_kwarg = 'id' + + def get_queryset(self): + qs = Revisions.objects.prefetch_related('experience').prefetch_related('reviewer').all() + if (qs.first().experience.verification_Status=="Approved"): + Revisions.objects.filter(experience=qs.first().experience).update(message="No Changes Required") + return qs + + +class CreateRevision(CreateAPIView): + serializer_class = RevisionSerializer + permission_classes = (IsMemberOrAbove,) + + def perform_create(self, serializer): + """ + the review codes are: + `acc` -> Accepted + `rev` -> Review Pending + `chg` -> Changes Requested + """ + req = self.request + curr_user = req.user + exp_id = self.kwargs.get('exp_id') + exp = get_object_or_404(Experiences, pk=exp_id) + review_code = self.kwargs.get('review_code', '') + print(self.kwargs) + if review_code == 'acc': # Accepted + exp.verification_Status = 'Approved' + exp.verifier = curr_user + # TODO + # Send mail to the author about publication + exp.save() + revisions = Revisions.objects.filter(experience=exp) + revisions.delete() + else: + msg = serializer.validated_data.get('message') + + if not msg: + raise ValidationError('Must provide message if not being accepted.') + domain = get_current_site(self.request).domain + exp_author = exp.user + subject = 'New Activity in Interview Experiences Section' + messages = [ + EmailMessage( + subject, + render_to_string( + 'changes_requested_email.html', + { + 'user': exp.user, + 'domain': domain, + 'experience': exp, + 'message': msg, + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, + } + ), + to=[exp.user.email], # exp.user.email + + ) + ] + + if review_code == 'rev': + # doesn't make sense at all + exp.verification_Status = 'Review Pending' + elif review_code == 'chg': + exp.verification_Status = 'Changes Requested' + exp.save() + revision, rev_created = Revisions.objects.get_or_create(experience=exp, + defaults={'reviewer': curr_user, + 'message': msg}) + else: + raise ValidationError("Invalid code for verification status.") + + # uncomment below to mail + threaded_mail = ThreadedMailing(messages, verbose=1) + threaded_mail.start() diff --git a/website/interview_exp/models.py b/website/interview_exp/models.py index 6879ee91..0e9f9f8d 100644 --- a/website/interview_exp/models.py +++ b/website/interview_exp/models.py @@ -4,16 +4,21 @@ from django.core.validators import MaxValueValidator, MinValueValidator from markdownx.models import MarkdownxField from markdownx.utils import markdownify +from django.urls import reverse +from django_prometheus.models import ExportModelOperationsMixin + # Create your models here. def current_year(): return datetime.date.today().year + def max_value_current_year(value): return MaxValueValidator(current_year())(value) -class Experiences(models.Model): + +class Experiences(ExportModelOperationsMixin('experience'), models.Model): company = models.CharField(max_length=100) year = models.PositiveIntegerField( default=current_year(), validators=[MinValueValidator(1984), max_value_current_year]) @@ -46,7 +51,7 @@ class Experiences(models.Model): @property def formatted_markdown(self): return markdownify(self.interview_Questions) - + def __str__(self): return str(self.company) + " " + str(self.user) @@ -54,6 +59,9 @@ def get_cname(self): class_name = "Experiences" return class_name + def get_absolute_url(self): + return reverse('experiences_api:ie_detail', kwargs={'slug': self.id}) + class Meta: managed = True ordering = ['-year', '-created_at'] @@ -61,7 +69,7 @@ class Meta: verbose_name_plural = 'Experiences' -class Revisions(models.Model): +class Revisions(ExportModelOperationsMixin('revision'), models.Model): experience = models.OneToOneField(Experiences, on_delete=models.CASCADE) reviewer = models.ForeignKey(User, on_delete=models.CASCADE) message = models.TextField() @@ -77,6 +85,9 @@ def get_cname(self): class_name = "Revisions" return class_name + def get_absolute_url(self): + return reverse('experiences_api:revision_detail', kwargs={'id': self.id}) + class Meta: managed = True ordering = ['-created_at'] diff --git a/website/interview_exp/templates/changes_requested_email.html b/website/interview_exp/templates/changes_requested_email.html index 2d2e5a36..7f06a1a1 100644 --- a/website/interview_exp/templates/changes_requested_email.html +++ b/website/interview_exp/templates/changes_requested_email.html @@ -2,9 +2,11 @@ Hi {{ user.username }}, Changes were requested in your Interview Experience by some Member/Admin. +They Said: +"{{ message }}" Kindly, make them by clicking on the link below: -http://{{ domain }}{% url 'interview_exp:detail_experiences' experience.id %} +{{ FRONTEND_BASE_URL }}{% url 'interview_exp:detail_experiences' experience.id %} Sincerely, RECursion Team diff --git a/website/interview_exp/templates/exp_detail.html b/website/interview_exp/templates/exp_detail.html index f109dee2..60a6aebe 100644 --- a/website/interview_exp/templates/exp_detail.html +++ b/website/interview_exp/templates/exp_detail.html @@ -52,7 +52,11 @@

    {{ experience.formatted_markdown | safe }}

    - img + {% if profile and profile.image %} + img + {% else %} + default profile + {% endif %}   {{experience.user}} diff --git a/website/interview_exp/templates/exp_list.html b/website/interview_exp/templates/exp_list.html index 976bac44..f0024f48 100644 --- a/website/interview_exp/templates/exp_list.html +++ b/website/interview_exp/templates/exp_list.html @@ -1,32 +1,51 @@ +{% load static %} + {% for experience in experiences %} -

    +
    {% for p in profile %} - {% if p.user == experience.user %} - img - {% endif %} + {% if p.user == experience.user %} + {% if p.image %} + img + {% else %} + default + {% endif %} + {% endif %} {% endfor %}
    +
    - {% if experience.verification_Status == "Approved" %} - {{ experience.company }} Interview Experience {{ experience.year }} # {{ experience.id }} - {% else %} - {{ experience.company }} Interview Experience {{ experience.year }} # {{ experience.id }} - {% endif %} + {% if experience.verification_Status == "Approved" %} + + {{ experience.company }} Interview Experience {{ experience.year }} #{{ experience.id }} + + {% else %} + + {{ experience.company }} Interview Experience {{ experience.year }} #{{ experience.id }} + + {% endif %} + {% if request.user == experience.user %} -
    - -
    +
    + +
    {% endif %} +
      -
    • {{ experience.job_Profile }} / {{experience.role_Type}}
    • +
    • + {{ experience.job_Profile }} / + {{ experience.role_Type }} +
    • -
    • Added by {{experience.user}}
    • - {% load staticfiles %} -
    • {{ experience.updated_at }}
    • +
    • + Added by {{ experience.user }} +
    • +
    • + {{ experience.updated_at }} +
    -
    - {% endfor %} +
    +{% endfor %} diff --git a/website/interview_exp/templates/new_experience_entry_email.html b/website/interview_exp/templates/new_experience_entry_email.html index cbca3274..9e224339 100644 --- a/website/interview_exp/templates/new_experience_entry_email.html +++ b/website/interview_exp/templates/new_experience_entry_email.html @@ -4,7 +4,7 @@ A new Interview Experience has been Added in Interview Experiences Section. Please, verify it by clicking on the link below: -http://{{ domain }}{% url 'interview_exp:detail_experiences' experience.id %} +{{ FRONTEND_BASE_URL }}{% url 'interview_exp:detail_experiences' experience.id %} Sincerely, RECursion Team diff --git a/website/interview_exp/templates/update_Experience_to_all_email.html b/website/interview_exp/templates/update_Experience_to_all_email.html index 60e8081b..eeccfcdf 100644 --- a/website/interview_exp/templates/update_Experience_to_all_email.html +++ b/website/interview_exp/templates/update_Experience_to_all_email.html @@ -4,7 +4,7 @@ An Interview Experience has been updated in Interview Experiences Section. Please, verify it by clicking on the link below: -http://{{ domain }}{% url 'interview_exp:detail_experiences' experience.id %} +{{ FRONTEND_BASE_URL }}{% url 'interview_exp:detail_experiences' experience.id %} Sincerely, RECursion Team diff --git a/website/interview_exp/templates/update_experience_email.html b/website/interview_exp/templates/update_experience_email.html index c6cee9af..2b67b5c7 100644 --- a/website/interview_exp/templates/update_experience_email.html +++ b/website/interview_exp/templates/update_experience_email.html @@ -4,7 +4,7 @@ An Interview Experience you reviewed has been updated in Interview Experience Section. Please, verify it by clicking on the link below: -http://{{ domain }}{% url 'interview_exp:detail_experiences' experience.id %} +{{ FRONTEND_BASE_URL }}{% url 'interview_exp:detail_experiences' experience.id %} Sincerely, RECursion Team diff --git a/website/interview_exp/views.py b/website/interview_exp/views.py index a5f2f5b6..60145078 100644 --- a/website/interview_exp/views.py +++ b/website/interview_exp/views.py @@ -1,4 +1,5 @@ from django.shortcuts import render, redirect,get_object_or_404, get_list_or_404 +from utils.content_filter import check_content_safety from .models import * from .forms import * from django.http import HttpResponse, HttpResponseRedirect @@ -11,12 +12,23 @@ from django.contrib.sites.shortcuts import get_current_site from django.template.loader import render_to_string from django.core.mail import send_mass_mail +from django.conf import settings +from django_ratelimit.decorators import ratelimit + @login_required +@ratelimit(key='user', rate='1/10m', method='POST', block=True) def add_experience(request): form = ExperienceForm(request.POST or None) if form.is_valid(): + interview_Questions = form.cleaned_data.get('interview_Questions') + company = form.cleaned_data.get('company') + job_profile = form.cleaned_data.get('job_Profile') + title = f"{company} - {job_profile}" + if not check_content_safety(interview_Questions, title): + form.add_error('interview_Questions', "Your post contains irrelevant/inappropriate content and cannot be published.") + return render(request, 'experience-form.html', {'form': form}) f = form.save(commit=False) f.user = request.user f.save() @@ -31,8 +43,9 @@ def add_experience(request): 'user': user, 'domain': current_site.domain, 'experience': Experiences.objects.get(pk=f.id), + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) result = send_mass_mail(messages, fail_silently=False) @@ -76,8 +89,9 @@ def update_experience(request, id): 'user': user, 'domain': current_site.domain, 'experience': Experiences.objects.get(pk=experience.id), + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) result = send_mass_mail(messages, fail_silently=False) @@ -93,8 +107,9 @@ def update_experience(request, id): 'user': user, 'domain': current_site.domain, 'experience': Experiences.objects.get(pk=experience.id), + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) result = send_mass_mail(messages, fail_silently=False) @@ -290,8 +305,9 @@ def revise_experience(request, id, action): 'user': user, 'domain': current_site.domain, 'experience': Experiences.objects.get(pk=experience.id), + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) result = send_mass_mail(messages, fail_silently=False) @@ -317,8 +333,9 @@ def revise_experience(request, id, action): 'user': user, 'domain': current_site.domain, 'experience': Experiences.objects.get(pk=experience.id), + 'FRONTEND_BASE_URL': settings.FRONTEND_BASE_URL, }) - msg = (subject, message, 'webmaster@localhost', [user.email]) + msg = (subject, message, settings.SERVER_EMAIL, [user.email]) if msg not in messages: messages += (msg,) result = send_mass_mail(messages, fail_silently=False) diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..4ff32e61 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "website", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/website/team/api/__init__.py b/website/team/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/team/api/filters.py b/website/team/api/filters.py new file mode 100644 index 00000000..6e5af6af --- /dev/null +++ b/website/team/api/filters.py @@ -0,0 +1,10 @@ +from django_filters import rest_framework as filters +from team.models import Members + + +class MembersFilter(filters.FilterSet): + batch_year = filters.NumberFilter(field_name='batch_year', label='batch') + + class Meta: + model = Members + fields = ['batch_year', ] diff --git a/website/team/api/permissions.py b/website/team/api/permissions.py new file mode 100644 index 00000000..06d8e7ff --- /dev/null +++ b/website/team/api/permissions.py @@ -0,0 +1,11 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class MembersListCreatePermission(BasePermission): + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + if not request.user.is_authenticated: + return False + role = request.user.profile.role + return role in ('1', '2') diff --git a/website/team/api/serilazers.py b/website/team/api/serilazers.py new file mode 100644 index 00000000..bfd24a6e --- /dev/null +++ b/website/team/api/serilazers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from team.models import Members + + +class MemberSerializer(serializers.ModelSerializer): + class Meta: + model = Members + exclude = ('id',) diff --git a/website/team/api/urls.py b/website/team/api/urls.py new file mode 100644 index 00000000..ff69082f --- /dev/null +++ b/website/team/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .views import ( + ListCreateMembersView, + AlumniYearWiseView +) + +app_name = 'team_api' + +urlpatterns = [ + path('', ListCreateMembersView.as_view(), name='members_list'), + path('alumni/', AlumniYearWiseView.as_view(), name='alumni_year_wise'), +] diff --git a/website/team/api/views.py b/website/team/api/views.py new file mode 100644 index 00000000..5f1acd43 --- /dev/null +++ b/website/team/api/views.py @@ -0,0 +1,40 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import ListCreateAPIView +from rest_framework.filters import SearchFilter, OrderingFilter + +from team.models import Members +from .serilazers import MemberSerializer +from .permissions import MembersListCreatePermission +from .filters import MembersFilter + +from rest_framework import generics +from team.models import Members +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from django_filters import rest_framework as filters +# SCOPE FOR CACHING # + +class ListCreateMembersView(ListCreateAPIView): + serializer_class = MemberSerializer + permission_classes = (MembersListCreatePermission,) + pagination_class = None + filter_backends = (SearchFilter, OrderingFilter, filters.DjangoFilterBackend) + filterset_class = MembersFilter + search_fields = ['name', 'branch', 'designation'] + ordering_fields = ['batch_year', 'name', 'designation', 'branch'] + + def get_queryset(self): + return Members.objects.all() + + + + +class AlumniYearWiseView(generics.ListAPIView): + serializer_class = MemberSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = MembersFilter + + def get_queryset(self): + year = self.request.query_params.get('year', None) + if year: + return Members.objects.filter(batch_year=year).order_by('-batch_year', 'name') + return Members.objects.all().order_by('-batch_year', 'name') # Default to all alumni if no year is specified diff --git a/website/team/templates/team/team.html b/website/team/templates/team/team.html index a19a6e0c..39bdfd3d 100644 --- a/website/team/templates/team/team.html +++ b/website/team/templates/team/team.html @@ -9,149 +9,170 @@ -

    Meet The Team

    - -
    -
    - {% for p in presi %} -
    -
    -
    - - -
    -

    {{ p.name }}

    - {{ p.designation }} -
    -
    - {% endfor %} - - {% for c in convener %} -
    -
    -
    - - -
    -

    {{ c.name }}

    - {{ c.designation }} -
    -
    - {% endfor %} - - {% for t in treasurer %} -
    -
    -
    - - -
    -

    {{ t.name }}

    - {{ t.designation }} -
    -
    - {% endfor %} - - {% for v in vice_presi %} -
    -
    -
    - - -
    -

    {{ v.name }}

    - {{ v.designation }} -
    -
    - {% endfor %} - - {% for g in gen_sec %} -
    -
    -
    - - -
    -

    {{ g.name }}

    - {{ g.designation }} -
    -
    - {% endfor %} - +
    +

    Meet The Team

    +
    + +
    +
    + {% for p in presi %} +
    +
    +
    + +
    -

    +

    {{ p.name }}

    + {{ p.designation }} +
    +
    + {% endfor %} + + {% for v in vice_presi %} +
    +
    +
    + + +
    +

    {{ v.name }}

    + {{ v.designation }} +
    +
    + {% endfor %} + + {% for t in treasurer %} +
    +
    +
    + + +
    +

    {{ t.name }}

    + {{ t.designation }} +
    +
    + {% endfor %} + +
    +
    + + {% for c in convener %} +
    +
    +
    + + +
    +

    {{ c.name }}

    + {{ c.designation }} +
    +
    + {% endfor %} + + {% for g in gen_sec %} +
    +
    +
    + + +
    +

    {{ g.name }}

    + {{ g.designation }} +
    +
    + {% endfor %} + +
    +

    + + + +
    +
    + + {% for member in members %} + {% if member.designation != "President" and member.designation != "Convenor" and member.designation != "Treasurer" and member.designation != "Vice President" and member.designation != "General Secretary" %} +
    +
    +
    + + +
    +

    {{ member.name }}

    + {{ member.designation }} +
    +
    + {% endif %} + {% endfor %} +
    +
    +
    - -
    -
    + +
    +

    Meet our Alumni

    +
    +
    +
    - {% for member in members %} - {% if member.designation != "President" and member.designation != "Convener" and member.designation != "Treasurer" and member.designation != "Vice President" and member.designation != "General Secretary" %} -
    -
    -
    - {{ member.name }}
    - {{ member.designation }} -
    -
    - {% endif %} - {% endfor %} + {% for year in year_set %} -
    -

    - - -

    Meet our Alumni

    -
    -
    - - {% for year in year_set %} - -

    Batch of {{ year }}

    - - {% for a in alumni %} - {% if a.batch_year == year %} - -
    -
    -
    -
    - -
    -
    - {{ a.name }}
    - B.Tech. in {{ a.branch }}
    - LinkedIn Profile -
    -
    -
    -
    - - {% endif %} - {% endfor %} - {% endfor %} +
    +

    Batch of {{ year }}

    +
    + + {% for a in alumni %} + {% if a.batch_year == year %} +
    +
    +
    +
    + +
    +
    + {{ a.name }}
    + B.Tech. in {{ a.branch }}
    + LinkedIn + Profile +
    +
    -{% endblock %} + + {% endif %} + {% endfor %} + {% endfor %} + +
    +
    +{% endblock %} \ No newline at end of file diff --git a/website/team/views.py b/website/team/views.py index c142869d..bf271e29 100644 --- a/website/team/views.py +++ b/website/team/views.py @@ -22,7 +22,7 @@ def team_page(request): year_set.append(a.batch_year) presi = Members.objects.filter(batch_year = curr_batch_year, designation = "President") - convener = Members.objects.filter(batch_year=curr_batch_year, designation="Convener") + convener = Members.objects.filter(batch_year=curr_batch_year, designation="Convenor") treasurer = Members.objects.filter(batch_year=curr_batch_year, designation="Treasurer") vice_presi = Members.objects.filter(batch_year=curr_batch_year, designation="Vice President") gen_sec = Members.objects.filter(batch_year=curr_batch_year, designation="General Secretary") diff --git a/website/user_profile/api/__init__.py b/website/user_profile/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/user_profile/api/serializers.py b/website/user_profile/api/serializers.py new file mode 100644 index 00000000..d15a6656 --- /dev/null +++ b/website/user_profile/api/serializers.py @@ -0,0 +1,121 @@ +from rest_framework import serializers +from rest_framework.validators import UniqueValidator +from django.contrib.auth.models import User + +from user_profile.models import Profile +from user_profile.utils import LowerEmailField + + +# class RegistrationSerializer(serializers.ModelSerializer): +# password2 = serializers.CharField(style={'input_type': 'password'}, write_only=True) +# email = LowerEmailField( +# required=True, +# allow_blank=False, +# label='Email address', +# max_length=30, +# validators=[UniqueValidator(queryset=User.objects.all())], +# ) + +# class Meta: +# model = User +# fields = ['username', 'email', 'password', 'password2'] +# extra_kwargs = { +# 'password': {'write_only': True} +# } + +# def save(self): +# password = self.validated_data['password'] +# password2 = self.validated_data['password2'] +# if password != password2: +# raise serializers.ValidationError({'confirm_password': 'Passwords must match!'}) +# account = User( +# username=self.validated_data['username'], +# email=self.validated_data['email'].lower(), +# is_active=False # TO BE CHANGED TO FALSE +# ) +# account.set_password(password) +# account.save() +# return account + +class RegistrationSerializer(serializers.ModelSerializer): + password2 = serializers.CharField(write_only=True) + dept = serializers.CharField(max_length=70, required=False, allow_blank=True) + + email = LowerEmailField( + required=True, + allow_blank=False, + max_length=30, + validators=[UniqueValidator(queryset=User.objects.all())], + ) + + class Meta: + model = User + fields = ['username', 'email', 'password', 'password2', 'dept'] + extra_kwargs = { + 'password': {'write_only': True} + } + + def validate(self, attrs): + if attrs['password'] != attrs['password2']: + raise serializers.ValidationError({ + 'password2': 'Passwords must match!' + }) + return attrs + + def create(self, validated_data): + validated_data.pop('password2') + dept = validated_data.pop('dept', None) + + user = User.objects.create_user( + username=validated_data['username'], + email=validated_data['email'].lower(), + password=validated_data['password'], + is_active=False + ) + + if dept: + user.profile.dept = dept + user.profile.save() + + return user + + + +class UserSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='user_profile_api:user_detail', lookup_field='username', + lookup_url_kwarg='username') + + class Meta: + model = User + fields = ['username', 'email', 'url'] + + +class ProfileSerializer(serializers.ModelSerializer): + user = UserSerializer() + role = serializers.CharField(source='get_role_display', ) + + class Meta: + model = Profile + exclude = ('id',) + read_only_fields = ('user', 'role', 'email_confirmed', 'created_at', 'updated_at',) + + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + + def validate_email(self, value): + if not User.objects.filter(email=value.lower()).exists(): + raise serializers.ValidationError("No user with that Email exists.") + return value.lower() + + +class PasswordResetConfirmSerializer(serializers.Serializer): + uidb64 = serializers.CharField() + token = serializers.CharField() + password = serializers.CharField(write_only=True) + confirm_password = serializers.CharField(write_only=True) + + def validate(self, attrs): + if attrs['password'] != attrs['confirm_password']: + raise serializers.ValidationError({"confirm_password": "Passwords must match."}) + return attrs diff --git a/website/user_profile/api/urls.py b/website/user_profile/api/urls.py new file mode 100644 index 00000000..1bc0e76e --- /dev/null +++ b/website/user_profile/api/urls.py @@ -0,0 +1,42 @@ +from django.urls import path + +from .views import ( + RegistrationView, + # ProfileRegistrationView, + ListProfileView, + RetrieveUpdateProfileView, + UserSearchView, + username_existence_check, + email_existence_check, + username_existence_check, + email_existence_check, + GetProfileRoleView, + RequestPasswordResetAPI, + ResetPasswordConfirmAPI, +) +from user_profile.views import activate + +app_name = 'user_profile_api' + +urlpatterns = [ + # Profile related(except first) + path('register/', RegistrationView.as_view(), name='user_registration'), + path('', ListProfileView.as_view(), name='all_users_list'), + # path('resend-user-activation/', resendVerificationView, name='resend-user-activation'), + + # Password Reset + path('password-reset/', RequestPasswordResetAPI.as_view(), name='password_reset_api'), + path('password-reset-confirm/', ResetPasswordConfirmAPI.as_view(), name='password_reset_confirm_api'), + + # Specific Checks and Views + path('search', UserSearchView.as_view(), name='user_search'), + path('username-check', username_existence_check, name='username_exists'), + path('email-check', email_existence_check, name='email_exists'), + path('roles', GetProfileRoleView.as_view(), name='roles'), + + # Account Activation + path('activate///', activate, name='activate'), + + # Profile Detail (Catch-all) + path('/', RetrieveUpdateProfileView.as_view(), name='user_detail'), +] diff --git a/website/user_profile/api/views.py b/website/user_profile/api/views.py new file mode 100644 index 00000000..4f242e72 --- /dev/null +++ b/website/user_profile/api/views.py @@ -0,0 +1,267 @@ +from django.contrib.auth.models import User +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.decorators import api_view, throttle_classes +from rest_framework.throttling import AnonRateThrottle +from rest_framework.response import Response +from rest_framework.generics import ( + CreateAPIView, + ListAPIView, + RetrieveUpdateAPIView, +) +from rest_framework.serializers import ValidationError +from django.contrib.sites.shortcuts import get_current_site +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework.permissions import IsAdminUser, IsAuthenticated, AllowAny + +from user_profile.models import Profile +from user_profile.utils import send_verification_mail +from user_profile.utils_permissions import ViewUpdatePermission +from user_profile.utils import ProfileMatcher + +import requests +from rest_framework_simplejwt.tokens import RefreshToken +from django.conf import settings +from django.core.files.base import ContentFile + + +from .serializers import ( + RegistrationSerializer, + UserSerializer, + UserSerializer, + ProfileSerializer, + PasswordResetRequestSerializer, + PasswordResetConfirmSerializer, +) +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from user_profile.tokens import password_reset_token +from django.template.loader import render_to_string + + + +# TODO +# 1) Email verification and resend email verification + +# For customised tokens. Don't use if not needed +class MyTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + refresh = self.get_token(self.user) + data['refresh'] = str(refresh) + data['access'] = str(refresh.access_token) + + # data['isStudent'] = self.user.groups.first().name == 'student' + # data['user'] = UserSerializer(self.user).data + return data + + +# Google Login api handler +class LoginWithGoogleView(APIView): + + def generate_token(self,user): + refresh = RefreshToken.for_user(user) + + refresh['email'] = user.email + + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + + def post(self, request): + access_token = request.data.get('token') + GOOGLE_CLIENT_ID = getattr(settings, "GOOGLE_CLIENT_ID", None) + if not GOOGLE_CLIENT_ID: + GOOGLE_CLIENT_ID = getattr(settings, "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", None) + + try: + TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v2/tokeninfo?access_token=" + access_token + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + token_info = requests.get(TOKEN_INFO_URL, data={}, headers=headers).json() + + if 'error' in token_info: + print(f"Google API Error: {token_info}") + return Response(data={'response': 'Invalid token from Google'}, status=400) + + if token_info['issued_to'] == GOOGLE_CLIENT_ID: + try: + USER_INFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?access_token=" + access_token + user_info = requests.get(USER_INFO_URL, data={}, headers=headers).json() + user, created = User.objects.get_or_create( + username=user_info['email'].split('@')[0], + defaults={ + 'first_name': user_info.get('name', ''), + 'last_name': user_info.get('given_name', ''), + 'email': user_info['email'] + } + ) + + if created: + try: + profile = user.profile + profile.name = user_info.get('name', '') + picture_url = user_info.get('picture') + if picture_url: + image_response = requests.get(picture_url) + if image_response.status_code == 200: + profile.image.save(f"{user.username}_google.jpg", ContentFile(image_response.content), save=False) + profile.save() + except Exception as e: + print(f"Error saving user profile data from Google: {e}") + + tokens = self.generate_token(user) + return Response(data={'access': tokens['access'], 'refresh': tokens['refresh'], 'response': 'valid', 'is_new_user': created}, status=200) + except Exception as e: + print(f"User creation/retrieval error: {e}") + return Response(data={'response': 'Unauthorized'}, status=401) + else: + print(f"Client ID mismatch. Expected: {GOOGLE_CLIENT_ID}, Got: {token_info.get('issued_to')}") + return Response(data={'response': 'Unauthorized - Client ID mismatch'}, status=401) + except Exception as e: + print(f"Top level error in LoginWithGoogleView: {e}") + return Response(data={'response': 'Invalid token'}, status=400) + + + +# For Getting The Role of the User +class GetProfileRoleView(APIView): + permission_classes = (IsAuthenticated,) + + def post(self, request): + try: + profile = Profile.objects.get(user=request.user) + return Response(data={'role': profile.role}, status=200) + except Exception as e: + print(e) + return Response(data={'response': 'Unauthorized'}, status=401) + + +# For customised tokens. Don't use if not needed +class MyTokenObtainPairView(TokenObtainPairView): + serializer_class = MyTokenObtainPairSerializer + + +class RegistrationView(CreateAPIView): + serializer_class = RegistrationSerializer + + def perform_create(self, serializer): + # is_active set to false in save method of the serializer + user = serializer.save() + domain = get_current_site(self.request).domain + _ = send_verification_mail(domain=domain, user=user) + # auto creation of profile done in model signal + + +class ListProfileView(ListAPIView): + serializer_class = ProfileSerializer + queryset = Profile.objects.all() + + +class RetrieveUpdateProfileView(RetrieveUpdateAPIView): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + permission_classes = (ViewUpdatePermission,) + lookup_field = 'user__username' + lookup_url_kwarg = 'username' + + def get_queryset(self): + return Profile.objects.all() + + +class UserSearchView(ListAPIView): + serializer_class = ProfileSerializer + + def get_queryset(self): + search_query = self.request.query_params.get('query') + qs = Profile.objects.all() + if not search_query: # returns all profiles in case of empty query + return qs + matcher = ProfileMatcher(query=search_query) + # using generator to save on space + unsorted_matches = ((matcher.matcher(i), i) for i in qs if matcher.matcher(i) >= 0.5) + return [i[1] for i in sorted(unsorted_matches, key=lambda x: x[0], reverse=True)] + + +@api_view(['GET', ]) +def username_existence_check(request): + search_query = request.query_params.get('username') + if (not search_query) or (not User.objects.filter(username=search_query).exists()): + return Response(data={'response': 'Username is available!', 'exists': 0}) + return Response(data={'response': 'Username already exists!', 'exists': 1}) + + +@api_view(['GET', ]) +def email_existence_check(request): + search_query = request.query_params.get('email') + if (not search_query) or (not User.objects.filter(email=search_query.lower()).exists()): + return Response(data={'response': 'Email ID is not used yet!', 'exists': 0}) + return Response(data={'response': 'An account is registered with the email address!', 'exists': 1}) + + +class RequestPasswordResetAPI(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data['email'] + user = User.objects.get(email=email) + + # Logic from existing view + current_site = get_current_site(request) + subject = 'Reset Your RECursion Account Password' + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + + # Using specific frontend URL construction + frontend_base = settings.FRONTEND_BASE_URL + + # Send email + # We reuse the template but ensure it uses the frontend link + # Or we can just send the link directly if the template allows + # The existing template likely uses 'uid' and 'token' to build a link. + # We should check if the template uses 'frontend_base_url' correctly. + + message = render_to_string('registration/password_reset_email.html', { + 'user': user, + 'domain': current_site.domain, + 'uid': uid, + 'token': token, + 'frontend_base_url': frontend_base, + }) + user.email_user(subject, message) + + return Response({"success": True, "message": "Password reset email sent."}) + return Response(serializer.errors, status=400) + + +class ResetPasswordConfirmAPI(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + def post(self, request): + serializer = PasswordResetConfirmSerializer(data=request.data) + if serializer.is_valid(): + uidb64 = serializer.validated_data['uidb64'] + token = serializer.validated_data['token'] + password = serializer.validated_data['password'] + + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + return Response({"error": "Invalid UID"}, status=400) + + if user is not None and password_reset_token.check_token(user, token): + user.set_password(password) + user.save() + return Response({"success": True, "message": "Password has been reset."}) + else: + return Response({"error": "Invalid or expired token"}, status=400) + + return Response(serializer.errors, status=400) diff --git a/website/user_profile/models.py b/website/user_profile/models.py index 981f2933..0d8fd3d2 100644 --- a/website/user_profile/models.py +++ b/website/user_profile/models.py @@ -10,18 +10,22 @@ from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.dispatch import receiver +from django.urls import reverse import os from PIL import Image from io import BytesIO -from django.core.files.uploadedfile import InMemoryUploadedFile -import sys +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django_prometheus.models import ExportModelOperationsMixin -def content_file_name(instance,filename): - ext="png" - filename= str(instance.user.username)+"."+str(ext) - return os.path.join('images/',filename) -class Profile(models.Model): +def content_file_name(instance, filename): + ext = "png" + filename = str(instance.user.username) + "." + str(ext) + return os.path.join('images/', filename) + + +class Profile(ExportModelOperationsMixin('profile'), models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) name = models.CharField(max_length=100) college = models.CharField(max_length=100) @@ -30,8 +34,8 @@ class Profile(models.Model): ('2', 'Member'), ('3', 'User') ) - role = models.CharField(max_length=50, choices=role_choices ,default='3') - dept = models.CharField(max_length=20, blank=True, null=True) + role = models.CharField(max_length=50, choices=role_choices, default='3') + dept = models.CharField(max_length=70, blank=True, null=True) url_CodeChef = models.URLField(blank=True, null=True) url_Codeforces = models.URLField(blank=True, null=True) url_SPOJ = models.URLField(blank=True, null=True) @@ -39,28 +43,52 @@ class Profile(models.Model): image_url = models.URLField(blank=True, null=True) image = models.ImageField(blank=True, null=True, upload_to=content_file_name) email_confirmed = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now=False, auto_now_add=True) - updated_at = models.DateTimeField(auto_now=False, auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.user.username - def save(self, *args, **kwargs): - if self.image: - img= Image.open(self.image) - output = BytesIO() - img = img.resize((100, 100)) - img.save(output, format='PNG', quality=100) - output.seek(0) - self.image = InMemoryUploadedFile(output, 'ImageField', ".png" , 'image/png', - sys.getsizeof(output), None) - super(Profile, self).save() + def save(self, *args, **kwargs): + previous_image_name = None + if self.pk: + previous = Profile.objects.filter(pk=self.pk).only('image').first() + if previous and previous.image: + previous_image_name = previous.image.name + + # Only process when a new image file is uploaded. + if self.image and not getattr(self.image, '_committed', True): + img = Image.open(self.image) + img = img.convert('RGB') + output = BytesIO() + img = img.resize((100, 100)) + img.save(output, format='PNG', quality=100) + output.seek(0) + + canonical_name = content_file_name(self, self.image.name or '') + if default_storage.exists(canonical_name): + default_storage.delete(canonical_name) + + self.image.save(canonical_name, ContentFile(output.getvalue()), save=False) + + super(Profile, self).save(*args, **kwargs) + + new_image_name = self.image.name if self.image else None + if ( + previous_image_name + and previous_image_name != new_image_name + and default_storage.exists(previous_image_name) + ): + default_storage.delete(previous_image_name) + + def get_absolute_url(self): + return reverse('user_profile_api:user_detail', kwargs={'username': self.user.username}) class Meta: managed = True + @receiver(post_save, sender=User) def update_user_profile(sender, instance, created, **kwargs): if created: Profile.objects.create(user=instance) - instance.profile.save() diff --git a/website/user_profile/templates/account_activation_email.html b/website/user_profile/templates/account_activation_email.html index 389d7a1f..7965b506 100644 --- a/website/user_profile/templates/account_activation_email.html +++ b/website/user_profile/templates/account_activation_email.html @@ -3,7 +3,7 @@ Please click on the link below to confirm your registration: -http://{{ domain }}{% url 'user_profile:activate' uidb64=uid token=token %} +http://{{ domain }}{% url 'user_profile_api:activate' uidb64=uid token=token %} Sincerely, RECursion Team diff --git a/website/user_profile/templates/profile/profile.html b/website/user_profile/templates/profile/profile.html index 7836cfb2..96f4c370 100644 --- a/website/user_profile/templates/profile/profile.html +++ b/website/user_profile/templates/profile/profile.html @@ -7,9 +7,14 @@
    - {% if profile.image %} -
    Profile Picture
    - {% endif %} + {% load static %} +
    + {% if profile and profile.image %} + Profile Picture + {% else %} + Profile Picture + {% endif %} +

    {{ profile.name }}

    @{{ profile.user }}
    diff --git a/website/user_profile/templates/registration/password_reset_email.html b/website/user_profile/templates/registration/password_reset_email.html index 2cdbb383..77863354 100644 --- a/website/user_profile/templates/registration/password_reset_email.html +++ b/website/user_profile/templates/registration/password_reset_email.html @@ -2,7 +2,7 @@ To initiate the password reset process for your {{ user.get_username }} RECursion Account, click the link below: -{{ protocol }}http://{{ domain }}{% url 'user_profile:password_reset_confirm' uidb64=uid token=token %} +{{ protocol }}{{ frontend_base_url }}{% url 'user_profile:password_reset_confirm' uidb64=uid token=token %} If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. diff --git a/website/user_profile/tokens.py b/website/user_profile/tokens.py index 3fa8b747..9fb533c9 100644 --- a/website/user_profile/tokens.py +++ b/website/user_profile/tokens.py @@ -1,5 +1,5 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.utils import six +import six class AccountActivationTokenGenerator(PasswordResetTokenGenerator): def _make_hash_value(self, user, timestamp): @@ -9,4 +9,4 @@ def _make_hash_value(self, user, timestamp): ) account_activation_token = AccountActivationTokenGenerator() -password_reset_token = AccountActivationTokenGenerator() \ No newline at end of file +password_reset_token = PasswordResetTokenGenerator() \ No newline at end of file diff --git a/website/user_profile/urls.py b/website/user_profile/urls.py index 4a11f503..9c3e38fb 100644 --- a/website/user_profile/urls.py +++ b/website/user_profile/urls.py @@ -7,12 +7,12 @@ app_name="profile" urlpatterns = [ path('account_activation_sent/', account_activation_sent, name='account_activation_sent'), - path('activate/([0-9A-Za-z_\-]+)/([0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/', + path('activate///', activate, name='activate'), path('change_password/', change_password, name='change_password'), path('password_reset/', password_reset, name='password_reset'), path('password_reset/done/', password_reset_done, name='password_reset_done'), - path('reset/([0-9A-Za-z_\-]+)/([0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/', + path('reset///', password_reset_confirm, name='password_reset_confirm'), path('reset/done/', password_reset_complete, name='password_reset_complete'), path('viewprofile//', view_profile, name='view_profile'), diff --git a/website/user_profile/utils.py b/website/user_profile/utils.py index 0c6ae2cc..7bc78440 100644 --- a/website/user_profile/utils.py +++ b/website/user_profile/utils.py @@ -1,4 +1,15 @@ +import threading +import six from difflib import SequenceMatcher +from typing import List, Tuple, Union +from django.core.exceptions import ValidationError +from django.core.mail import EmailMessage +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.template.loader import render_to_string +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.conf import settings +from rest_framework import serializers class ProfileMatcher: @@ -26,4 +37,77 @@ def matcher(self, obj): return 0 def __str__(self): - return 'ProfileMatcher object with query="{}", ratio_threshold={} .'.format(self.query,self.ratio_threshold) + return 'ProfileMatcher object with query="{}", ratio_threshold={} .'.format(self.query, self.ratio_threshold) + + +from .tokens import account_activation_token + + +class LowerEmailField(serializers.EmailField): + def to_representation(self, value): + return str(value) + + def to_internal_value(self, data): + if isinstance(data, bool) or not isinstance(data, (str, int, float,)): + self.fail('invalid') + value = str(data).lower() + return value.strip() if self.trim_whitespace else value + + +class ThreadedMailing(threading.Thread): + def __init__(self, emails: List[EmailMessage], fail_silently: bool = True, verbose: int = 0): + self.email_msgs = emails + self.fail_silently = fail_silently + self.verbose = verbose + threading.Thread.__init__(self) + + def run(self): + if self.verbose > 0: + print('Sending {} emails.'.format(len(self.email_msgs))) + for email_number, email in enumerate(self.email_msgs, start=1): + if self.verbose > 1: + print('Sending email number:', email_number) + print(email) + email.send(fail_silently=self.fail_silently) + + +# needs change, UNFIT FOR USE +def send_verification_mail(domain, user, *args, **kwargs): + if domain is None or user is None: + raise ValidationError('Domain/User instance not provided') + mail_subject = 'Activate your RECursion website account.' + message = render_to_string('account_activation_email.html', { + 'user': user, + 'domain': domain, + 'uid': urlsafe_base64_encode(force_bytes(user.id)), + 'token': account_activation_token.make_token(user), + }) + to_email = user.email + mail = EmailMessage( + mail_subject, + message, + to=[to_email, ] + ) + + # UNCOMMENT BELOW LINES TO SEND EMAIL + threaded_mail = ThreadedMailing([mail, ]) + threaded_mail.start() + + return message + + +def valid_url_extension(url, allowed=('.jpg', '.jpeg', '.png', '.gif', '.webp')): + """ + Return True if the URL's path ends with an allowed image extension. + Keeps a lightweight check so views can safely attempt to download image files. + """ + try: + from urllib.parse import urlparse + path = urlparse(url).path or '' + path = path.lower() + for ext in allowed: + if path.endswith(ext): + return True + return False + except Exception: + return False diff --git a/website/user_profile/utils_permissions.py b/website/user_profile/utils_permissions.py new file mode 100644 index 00000000..c5d66e0e --- /dev/null +++ b/website/user_profile/utils_permissions.py @@ -0,0 +1,19 @@ +from rest_framework.permissions import IsAuthenticated, SAFE_METHODS, BasePermission +from user_profile.models import Profile + + +class ViewUpdatePermission(BasePermission): + def has_permission(self, request, view): + return (request.method in SAFE_METHODS) or bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return bool(request.user and request.user.is_authenticated) and request.user == obj.user + + +class IsMemberOrAbove(IsAuthenticated): + def has_object_permission(self, request, view, obj): + curr_user = request.user + curr_profile = Profile.objects.get(user=curr_user) + return curr_profile.role != '3' diff --git a/website/user_profile/views.py b/website/user_profile/views.py index 9cdd6e90..afda95e6 100644 --- a/website/user_profile/views.py +++ b/website/user_profile/views.py @@ -1,42 +1,34 @@ -from django.shortcuts import render, redirect,get_object_or_404, get_list_or_404 +from django.shortcuts import render, redirect, get_object_or_404, get_list_or_404 from .models import * from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse from django.template import loader, RequestContext +from django.template.loader import render_to_string from .forms import * from django.contrib.auth.models import User -from django.contrib.auth import authenticate, login, logout +from django.contrib.auth import authenticate, login, logout, update_session_auth_hash from django.contrib.auth.decorators import login_required -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import UserCreationForm, PasswordChangeForm, SetPasswordForm from django.views.decorators.csrf import csrf_exempt from django.conf import settings from django.forms import modelformset_factory -from django.contrib.auth.forms import UserCreationForm from itertools import chain from django.core.files.base import ContentFile from io import BytesIO import urllib.request from PIL import Image from django.contrib.sites.shortcuts import get_current_site -from django.shortcuts import render, redirect from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_encode -from django.template.loader import render_to_string -from .tokens import account_activation_token -from .tokens import password_reset_token from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode -from django.utils.encoding import force_bytes, force_text +from .tokens import account_activation_token, password_reset_token from django.contrib import messages -from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.forms import PasswordChangeForm -from django.shortcuts import render, redirect -from django.contrib.auth.forms import SetPasswordForm -from django.contrib.auth.models import * +from .utils import ProfileMatcher, valid_url_extension +from website.utils import save_local_profile_pic_to_media import random from forum.models import * from blog.models import * -from .utils import ProfileMatcher from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from rest_framework_simplejwt.tokens import RefreshToken def view_profile(request, id=None): @@ -109,10 +101,12 @@ def user_register(request): user.save() current_site = get_current_site(request) subject = 'Activate Your RECursion Account' + # ensure uid is a clean string (urlsafe_base64_encode returns str in recent Django) + uid = urlsafe_base64_encode(force_bytes(user.pk)) message = render_to_string('account_activation_email.html', { 'user': user, 'domain': current_site.domain, - 'uid': urlsafe_base64_encode(force_bytes(user.pk)).decode(), + 'uid': uid, 'token': account_activation_token.make_token(user), }) user.email_user(subject, message) @@ -170,17 +164,22 @@ def activate(request, uidb64, token, backend='django.contrib.auth.backends.Model user.save() login(request, user, backend='django.contrib.auth.backends.ModelBackend') profile = Profile.objects.get(user = user) - image_url = 'https://recursionnitd.in/'+'static/image/profile_pic/' + str(random.randint(1,15)) + '.png' - type = valid_url_extension(image_url) - full_path = 'media/images/' + profile.user.username + '.png' + # Copy a local static profile pic into MEDIA and set ImageField try: - urllib.request.urlretrieve(image_url, full_path) - except: - return HttpResponse("Downloadable Image Not Found!") - if profile.user == request.user: - profile.image = '../' + full_path + rel_media = save_local_profile_pic_to_media(profile.user.username) + except Exception as e: + print("activate: save_local_profile_pic_to_media exception:", repr(e)) + rel_media = False + + if not rel_media: + print("Downloadable Image Not Found!") + else: + profile.image.name = rel_media # set path relative to MEDIA_ROOT profile.save() - return redirect('user_profile:edit_profile') + + if profile.user == request.user: + return redirect(settings.FRONTEND_BASE_URL + '/profile/edit?activated=true') + return redirect(settings.FRONTEND_BASE_URL + '/profile/edit?activated=true') else: return render(request, 'account_activation_invalid.html') @@ -193,17 +192,16 @@ def edit_profile(request): form = Profileform(request.POST or None, request.FILES or None, instance=profile) if form.is_valid(): form.save() - if form.cleaned_data['image'] is None or form.cleaned_data['image'] == False: - image_url = 'https://recursionnitd.in/'+'static/image/profile_pic/' + str(random.randint(1,15)) + '.png' - type = valid_url_extension(image_url) - full_path = 'media/images/' + profile.user.username + '.png' - try: - urllib.request.urlretrieve(image_url, full_path) - except: - return HttpResponse("Downloadable Image Not Found!") - if profile.user == request.user: - profile.image = '../' + full_path - form.save() + # ensure a local default exists if no image supplied + if not profile.image: + try: + rel_media = save_local_profile_pic_to_media(profile.user.username) + except Exception as e: + print("edit_profile: save_local_profile_pic_to_media exception:", repr(e)) + rel_media = False + if rel_media: + profile.image.name = rel_media + profile.save() return HttpResponseRedirect(reverse('user_profile:view_profile', args=(id,))) return render(request, 'create.html', {'form': form, }) @@ -216,7 +214,7 @@ def change_password(request): user = form.save() update_session_auth_hash(request, user) # Important! messages.success(request, 'Your password was successfully updated!') - return redirect('profile:edit_profile') + return redirect('user_profile:edit_profile') else: messages.error(request, 'Please correct the error below.') else: @@ -237,11 +235,13 @@ def password_reset(request): user.save() current_site = get_current_site(request) subject = 'Reset Your RECursion Account Password' + uid = urlsafe_base64_encode(force_bytes(user.pk)) message = render_to_string('registration/password_reset_email.html', { 'user': user, 'domain': current_site.domain, - 'uid': urlsafe_base64_encode(force_bytes(user.pk)).decode(), + 'uid': uid, 'token': password_reset_token.make_token(user), + 'frontend_base_url': settings.FRONTEND_BASE_URL, }) user.email_user(subject, message) return HttpResponse("We've emailed you instructions for setting your password, if an account exists with the email you entered! You should receive them shortly." @@ -262,15 +262,26 @@ def password_reset_confirm(request, uidb64, token, backend='django.contrib.auth. except (TypeError, ValueError, OverflowError, User.DoesNotExist): user = None - if user is not None and account_activation_token.check_token(user, token): - form = SetPasswordForm(user, request.POST) - if form.is_valid(): - user = form.save() - update_session_auth_hash(request, user) # Important! - user.is_active = True - user.save() - return redirect('login') - return render(request, 'registration/password_reset_confirm.html', {'form': form}) + + # Use password_reset_token (was incorrectly using account_activation_token) + if user is not None and password_reset_token.check_token(user, token): + if request.method == 'POST': + data = { + 'new_password1': request.POST.get('password'), + 'new_password2': request.POST.get('confirmPassword'), + } + form = SetPasswordForm(user, data=data) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + user.is_active = True + user.save() + return HttpResponse("Changed.") + # if invalid, re-render with errors + return HttpResponse(form) + else: + form = SetPasswordForm(user) + return render(request, 'registration/password_reset_confirm.html', {'form': form}) else: return render(request, 'registration/password_reset_invalid.html') diff --git a/website/utils/__init__.py b/website/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/utils/content_filter.py b/website/utils/content_filter.py new file mode 100644 index 00000000..2c39b408 --- /dev/null +++ b/website/utils/content_filter.py @@ -0,0 +1,100 @@ +import os +import requests +import json + +def check_content_safety(text, title=None): + """ + Checks if the provided text (and optional title) is safe using the Gemini API. + Returns: + - True: if content is safe + - False: if content is unsafe + """ + api_key = os.environ.get("GEMINI_API_KEY") + api_url = os.environ.get("GEMINI_API_URL") + + if not api_key or not api_url: + print("Gemini API credentials missing in .env") + return True # Fail open if not configured + + headers = { + "Content-Type": "application/json" + } + + # Append key to URL if not present (standard Gemini API pattern) + if "?key=" not in api_url and "&key=" not in api_url: + final_url = f"{api_url}?key={api_key}" + else: + final_url = api_url + + content_to_analyze = f"Title: {title}\n" if title else "" + content_to_analyze += f"Body: {text}" + + prompt = f""" + You are an AI Content Moderator for a college technical club website. Your goal is to filter user submissions for a Blog, Ask Section, Interview Experience Section. The environment must remain professional, academic, and safe. + + Analyze the input text (Title and Body) based on the following criteria: + + 1. **SPAM & ADVERTISING (Strict Ban):** + - Flag any promotion of pharmaceuticals, supplements, "useless medicines," weight loss pills, or male enhancement products. + - Flag any commercial advertising, affiliate links, or "get rich quick" schemes. + - Flag any text that looks like SEO spam (repetitive keywords without context). + + 2. **SAFETY & TOXICITY (Strict Ban):** + - Flag any sexual content, NSFW material, or innuendo. + - Flag hate speech, harassment, or bullying. + - Flag content encouraging self-harm or violence. + + 3. **RELEVANCE (Allow):** + - Allow questions about coding, engineering, science, and technology. + - Allow general curiosity questions suitable for an academic environment. + - Allow constructive discussions about college life or club activities. + + 4. **TONE:** + - The content must be professional or semi-professional. + - Reject gibberish or incoherent text. + + Respond ONLY with a JSON object in the following format: + {{ + "is_safe": true/false, + "reason": "brief explanation if unsafe" + }} + + Text to analyze: + {content_to_analyze} + """ + + data = { + "contents": [{ + "parts": [{"text": prompt}] + }] + } + + try: + response = requests.post(final_url, headers=headers, json=data, timeout=10) + response.raise_for_status() + result = response.json() + + # Extract text response + try: + content_text = result['candidates'][0]['content']['parts'][0]['text'] + + # Clean potential markdown formatting + content_text = content_text.strip() + if content_text.startswith("```json"): + content_text = content_text[7:] + if content_text.startswith("```"): + content_text = content_text[3:] + if content_text.endswith("```"): + content_text = content_text[:-3] + + parsed = json.loads(content_text) + return parsed.get("is_safe", True) + + except (KeyError, IndexError, json.JSONDecodeError) as e: + print(f"Error parsing Gemini response: {e}") + print(f"Response body: {result}") + return True # Fail open on parse error + + except Exception as e: + print(f"Error checking content safety: {e}") + return True # Fail open on network/API error diff --git a/website/website/middleware.py b/website/website/middleware.py new file mode 100644 index 00000000..6ee6b4d2 --- /dev/null +++ b/website/website/middleware.py @@ -0,0 +1,62 @@ +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import render + +class APIModeMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # 1. Check if strict API mode is enabled + if not getattr(settings, 'API_ONLY_MODE', False): + return self.get_response(request) + + # 2. Check if the path is allowed + path = request.path + + # Always allow these prefixes + allowed_prefixes = [ + '/api/', + '/admin/', + '/static/', + '/media/', + '/oauth/', + ] + + # Add whitelisted paths from settings + whitelist = getattr(settings, 'API_MODE_WHITELIST', []) + + # Helper to check permissions + is_allowed = False + + # Check standard prefixes + for prefix in allowed_prefixes: + if path.startswith(prefix): + is_allowed = True + break + + # Check whitelist (can be exact match or prefix) + if not is_allowed: + for item in whitelist: + # If item ends with slash, treat as prefix? + # Ideally, simple startswith check is flexible enough + if path.startswith(item): + is_allowed = True + break + + if is_allowed: + return self.get_response(request) + + # 3. Block access + # Return a simple unauthorized HTML page + content = """ + + Unauthorized + +
    +

    APIs are routed through this! (●'◡'●)

    +
    + + + """ + return HttpResponse(content, status=200) # Using 200 so it renders nicely in browser, or could use 403 diff --git a/website/website/settings.py b/website/website/settings.py index c4edb438..f6e86f4f 100644 --- a/website/website/settings.py +++ b/website/website/settings.py @@ -8,23 +8,48 @@ """ import os -from decouple import config +from dotenv import load_dotenv +# Always load .env from the Django project root, regardless of current cwd. +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +from decouple import config, Csv +from datetime import timedelta +import dj_database_url # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = '9%b%-x(_!zd(ffdc!s=8j(clv&(_92d!+lh@#o9&t8*y40v1+3' +# GOOGLE_CLIENT_ID +GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', None) + + # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='127.0.0.1,localhost', cast=Csv()) + -ALLOWED_HOSTS = ['*'] +API_ONLY_MODE = config('API_ONLY_MODE', default=False, cast=bool) +API_MODE_WHITELIST = [] +FRONTEND_BASE_URL = config('FRONTEND_BASE_URL', default='http://localhost:3000') + +# Add explicit local dev origins by default while keeping env-driven control. +CORS_ALLOWED_ORIGINS = config( + 'CORS_ALLOWED_ORIGINS', + default='http://localhost:3000,http://127.0.0.1:3000,https://www.recursionnitd.in', + cast=Csv(), +) + +if FRONTEND_BASE_URL and FRONTEND_BASE_URL not in CORS_ALLOWED_ORIGINS: + CORS_ALLOWED_ORIGINS.append(FRONTEND_BASE_URL) + +CORS_ALLOW_CREDENTIALS = True # Application definition @@ -46,23 +71,31 @@ 'markdownx', 'blog', 'getting_started', + 'rest_framework', + 'django_filters', + 'django_prometheus', 'events_calendar', + 'corsheaders', + 'rest_framework_simplejwt.token_blacklist' ] MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', - #'django.middleware.csrf.CsrfViewMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - + 'django_prometheus.middleware.PrometheusAfterMiddleware', + 'website.middleware.APIModeMiddleware', ] AUTHENTICATION_BACKENDS = ( 'social_core.backends.open_id.OpenIdAuth', # for Google authentication - 'social_core.backends.google.GoogleOpenId', # for Google authentication + # 'social_core.backends.google.GoogleOpenId', # for Google authentication 'social_core.backends.google.GoogleOAuth2', # for Google authentication 'social_core.backends.facebook.FacebookOAuth2', # for Facebook authentication @@ -85,24 +118,73 @@ 'social_django.context_processors.backends', 'social_django.context_processors.login_redirect', ], + 'libraries': { + 'staticfiles': 'django.templatetags.static', + } }, }, ] WSGI_APPLICATION = 'website.wsgi.application' +# REST FRAMEWORK settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', -# Database -# https://docs.djangoproject.com/en/2.1/ref/settings/#databases + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10, -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } + 'DEFAULT_THROTTLE_RATES': { + 'anon': '40/day', + }, + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' } + + +# JWT SETTINGS +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=28), # TO BE REDUCED + 'REFRESH_TOKEN_LIFETIME': timedelta(days=56), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + 'JTI_CLAIM': 'jti', + + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), +} + +# Database +# https://docs.djangoproject.com/en/2.1/ref/settings/#databases +DATABASES = { + 'default': dj_database_url.config( + default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3") + ) +} # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators @@ -121,22 +203,20 @@ }, ] - - SOCIAL_AUTH_PIPELINE = ( - 'social.pipeline.social_auth.social_details', - 'social.pipeline.social_auth.social_uid', - 'social.pipeline.social_auth.auth_allowed', - 'social.pipeline.social_auth.social_user', - + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.auth_allowed', + 'social_core.pipeline.social_auth.social_user', + 'website.utils.associate_by_email', - - 'social.pipeline.user.get_username', - 'social.pipeline.user.create_user', - 'social.pipeline.social_auth.associate_user', - 'social.pipeline.social_auth.load_extra_data', - 'social.pipeline.user.user_details', - + + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', + 'website.utils.set_image_for_new_users' ) @@ -145,11 +225,9 @@ LOGOUT_REDIRECT_URL = 'home' LOGIN_REDIRECT_URL = 'home' - SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = config('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY') SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = config('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET') - # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ @@ -163,20 +241,39 @@ USE_TZ = True - +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # to silence the 26 errors # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.1/howto/static-files/ STATIC_URL = '/static/' -MEDIA_URL ='/media/' +MEDIA_URL = '/media/' + +MEDIA_ROOT = os.path.join(BASE_DIR, '../website/media') + +# Select backend via env; default stays console for development +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') + +# Sender addresses +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL') +SERVER_EMAIL = config('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL) + +# SMTP settings (used when EMAIL_BACKEND is set to SMTP) +EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_PASSWORD', default='') -MEDIA_ROOT=os.path.join(BASE_DIR,'../website/media') +TRIAL_REC_MAIL = 'jiwegaw290@randrai.com' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +# STATICFILES_DIRS = [ +# os.path.join(BASE_DIR, 'website/static'), +# ] -#PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) -#STATIC_ROOT = os.path.join(PROJECT_DIR, 'static') -#MEDIA_URL='/media/' -#MEDIA_ROOT=os.path.join(BASE_DIR,'members/media') +# PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) +# STATIC_ROOT = os.path.join(PROJECT_DIR, 'static') +# MEDIA_URL='/media/' +# MEDIA_ROOT=os.path.join(BASE_DIR,'members/media') \ No newline at end of file diff --git a/website/website/urls.py b/website/website/urls.py index f7d73d38..86ce87d2 100755 --- a/website/website/urls.py +++ b/website/website/urls.py @@ -15,28 +15,50 @@ """ from django.contrib import admin from django.contrib.auth import views as auth_views -from django.urls import path,include +from django.urls import path, include from django.conf.urls import url, include from forum import views from django.conf import settings from django.conf.urls.static import static +from rest_framework_simplejwt.views import ( + TokenRefreshView, +) +from user_profile.api.views import MyTokenObtainPairView, LoginWithGoogleView +from forum.api.views import home_view urlpatterns = [ - path('admin/', admin.site.urls), - path('', views.home, name='home'), - path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True), name='login'), - path('logout/', auth_views.LogoutView.as_view(), name='logout'), - path('oauth/', include('social_django.urls', namespace='social')), - path('forum/',include('forum.urls',namespace='forum')), - # path('events/',include('events.urls',namespace='events')), - path('events/',include('events_calendar.urls',namespace='events_calendar')), - path('profile/',include('user_profile.urls',namespace='user_profile')), - path('team/',include('team.urls',namespace='team')), - path('blog/',include('blog.urls',namespace='blog')), - path('experience/',include('interview_exp.urls',namespace='interview_exp')), - path('get_started/',include('getting_started.urls',namespace='getting_started')), - # path('members/',include('members.urls')), - url(r'^markdownx/', include('markdownx.urls')), + path('admin/', admin.site.urls), + path('', views.home, name='home'), + path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True), name='login'), + path('logout/', auth_views.LogoutView.as_view(), name='logout'), + path('oauth/', include('social_django.urls', namespace='social')), + path('forum/', include('forum.urls', namespace='forum')), + # path('events/',include('events.urls',namespace='events')), + path('events/', include('events_calendar.urls', namespace='events_calendar')), + path('profile/', include('user_profile.urls', namespace='user_profile')), + path('team/', include('team.urls', namespace='team')), + path('blog/', include('blog.urls', namespace='blog')), + path('experience/', include('interview_exp.urls', namespace='interview_exp')), + path('get_started/', include('getting_started.urls', namespace='getting_started')), + # path('members/',include('members.urls')), + url(r'^markdownx/', include('markdownx.urls')), -]+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + # API region + + # API URLs + path('api/', home_view, name='api_home'), + path('api/users/', include('user_profile.api.urls', namespace='user_profile_api')), + path('api/experiences/', include('interview_exp.api.urls', namespace='experiences_api')), + path('api/events/', include('events_calendar.api.urls', namespace='events_api')), + path('api/team/', include('team.api.urls', namespace='team_api')), + path('api/getting_started/', include('getting_started.api.urls', namespace='getting_started_api')), + + # JWT + path('api/token/google/', LoginWithGoogleView.as_view(), name='token_for_google'), + path('api/token/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + # Prometheus monitoring + path('', include('django_prometheus.urls')), + + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/website/website/utils.py b/website/website/utils.py index d5aaf6f6..0beadff5 100644 --- a/website/website/utils.py +++ b/website/website/utils.py @@ -1,8 +1,15 @@ from django.contrib.auth.models import User from user_profile.models import Profile -import urllib.request +import os import random +import urllib.request +from urllib.error import URLError, HTTPError +import mimetypes +from django.core.files.base import ContentFile +from django.conf import settings from django.shortcuts import redirect +from django.contrib.staticfiles import finders +import shutil def associate_by_email(**kwargs): try: @@ -16,6 +23,28 @@ def associate_by_email(**kwargs): pass return kwargs +def _save_image_from_url_to_profile(user, image_url): + """ + Download image_url and save into user.profile.image using Django storage. + Returns True on success, False on failure. Saves to 'images/.'. + """ + try: + # Request with a common User-Agent to avoid blocking by some servers + req = urllib.request.Request(image_url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=10) as resp: + data = resp.read() + # try to get content-type to derive extension + content_type = resp.info().get_content_type() + ext = mimetypes.guess_extension(content_type) or '.png' + filename = f"images/{user.username}{ext}" + # Save using Django File storage so ImageField works as expected + user.profile.image.save(filename, ContentFile(data), save=True) + return True + except (HTTPError, URLError, ValueError, Exception) as e: + # print full exception so you can diagnose (network, ssl, 403, etc.) + print("profile image download failed:", repr(e)) + return False + def set_image_for_new_users(backend, user, response, *args, **kwargs): #import pdb;pdb.set_trace(); try: @@ -32,22 +61,63 @@ def set_image_for_new_users(backend, user, response, *args, **kwargs): user.save() if not user.profile.image: - full_path = 'media/images/' + user.username + '.png' + # try provider picture first try: - image_url = response['picture'] - urllib.request.urlretrieve(image_url, full_path) - user.profile.image = '../' + full_path - user.save() - except: - try: - image_url = 'https://recursionnitd.in/'+'static/image/profile_pic/' + str(random.randint(1,15)) + '.png' - urllib.request.urlretrieve(image_url, full_path) - user.profile.image = '../' + full_path - user.save() - except: + image_url = response.get('picture') + if image_url: + ok = _save_image_from_url_to_profile(user, image_url) + if ok: + return + # fallback to site default + image_url = 'https://recursionnitd.in/static/image/profile_pic/' + str(random.randint(1,15)) + '.png' + ok = _save_image_from_url_to_profile(user, image_url) + if not ok: print("Downloadable Image Not Found!") - + except Exception as e: + print("set_image_for_new_users error:", repr(e)) + print("Downloadable Image Not Found!") except Exception as e: - print(e) - print("error") - pass \ No newline at end of file + print("set_image_for_new_users outer error:", repr(e)) + pass + +def save_local_profile_pic_to_media(username): + """ + Copy a random profile pic from static/image/profile_pic/<1-15>.png + into MEDIA_ROOT/images/.png. + + Returns the relative path to MEDIA (e.g. 'images/username.png') on success, + or False on failure. + Works on Linux and Windows by using os.path.join and Django settings. + """ + try: + n = random.randint(1, 15) + rel_static_path = os.path.join('image', 'profile_pic', f'{n}.png') + + # 1) Try Django staticfiles finders (dev & when using collectstatic + staticfiles app) + src = finders.find(rel_static_path) + + # 2) Fallback to STATIC_ROOT (deployed collected static) + if not src and getattr(settings, 'STATIC_ROOT', None): + src = os.path.join(settings.STATIC_ROOT, rel_static_path) + + # 3) Fallback to project-level "static" directory (common in development) + if not src: + src = os.path.join(getattr(settings, 'BASE_DIR', ''), 'static', rel_static_path) + + if not src or not os.path.exists(src): + print("local static profile pic not found:", rel_static_path, "resolved to:", src) + return False + + media_root = getattr(settings, 'MEDIA_ROOT', None) or os.path.join(getattr(settings, 'BASE_DIR', ''), 'media') + dest_dir = os.path.join(media_root, 'images') + os.makedirs(dest_dir, exist_ok=True) + + dest_path = os.path.join(dest_dir, f"{username}.png") + shutil.copyfile(src, dest_path) + + # return path relative to MEDIA_ROOT (ImageField stores relative paths) + relative_media_path = os.path.join('images', f"{username}.png").replace('\\', '/') + return relative_media_path + except Exception as e: + print("save_local_profile_pic_to_media error:", repr(e)) + return False \ No newline at end of file