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..004c910a 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,180 @@ -# RECursionNITD-website +# RECursion NITD Website Backend -steps to install +## Project Title -1. create python virtualenv -2. install all packages from requirements.txt -3. create .env file in project root (website/) with dbdetails +**RECursion NITD Website Backend** -sample: +## Functionalities +- User registration, login, and profile management +- Forum with questions, answers, and discussions +- Event creation, listing, updating, and details +- Interview experience sharing and browsing +- Blog content management +- Team member pages and team-related data +- Calendar-related event APIs +- Google social authentication +- Email notification support using SMTP +- REST API endpoints for frontend integration + +## Tech Stack + +- Python 3.11 +- Django 3.2 +- Django REST Framework +- Django Filters +- Django MarkdownX +- Django Prometheus +- social-auth-app-django +- dj-database-url +- python-decouple / python-dotenv + +## Project Structure + +- `requirements.txt` — project dependencies +- `website/` — Django application root + - `manage.py` — Django management script + - `website/` — project settings and URL configuration + - `settings.py` — application settings + - `urls.py` — root URL routing + - `wsgi.py` — WSGI entrypoint + - `blog/` — blog application + - `events/` — event management application + - `events_calendar/` — calendar API application + - `forum/` — forum application + - `getting_started/` — onboarding and guides app + - `team/` — team management app + - `user_profile/` — user profile and account app + - `interview_exp/` — interview experience app + - `utils/` — shared utilities + +## Local Setup + +### 1. Prerequisites + +- Python 3.11 +- pip +- Git +- Optional: Microsoft Visual C++ Build Tools if package compilation fails + +#### Install Python 3.11 + +- Download Python 3.11 from the official website: + https://www.python.org/downloads/release/python-3110/ +- *Windows (via winget)*: winget install -e --id Python.Python.3.11 +- *macOS (via Homebrew)*: brew install python@3.11 +- *Linux (Ubuntu/Debian)*: sudo apt update && sudo apt install python3.11 python3.11-venv + + +Verify Python version: + +```python --version``` + +Expected output: + +```Python 3.11.x``` + +### 2. Fork and Clone the Repository + +- Go to: + https://github.com/RECursion-NITD/RECursionNITD-website + +- Click Fork to create your own copy + +- Clone your fork: + +``` +git clone https://github.com//RECursionNITD-website.git +cd RECursionNITD-website + +Add upstream remote: + +git remote add upstream https://github.com/RECursion-NITD/RECursionNITD-website.git +git remote -v + +Checkout development branch: + +git checkout dev-api ``` -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='googlekey' -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET='googleSECRET' +### 3. Create a virtual environment + +```powershell +python -m venv venv ``` -4. run ```python manage.py makemigrations``` -5. run ```python manage.py migrate``` -6. run ```python manage.py runserver``` +### 4. Activate the virtual environment + +```powershell +.\venv\Scripts\Activate.ps1 +``` + +If you are using Command Prompt: + +```cmd +venv\Scripts\activate.bat +``` + +### 5. Install dependencies + +```powershell +python -m pip install --upgrade pip setuptools wheel +python -m pip install -r requirements.txt +``` + +### 6. Add the database file + +Place the downloaded database file here: + +```text +website/db.sqlite3 +``` + +### 7. Extract media files + +Extract the downloaded `media.zip` into the `website/` folder. + +### 8. Create `website/.env` + +Create the env file and add the required configuration values. Do not commit secrets. + +### 9. Apply database migrations + +```powershell +cd website +python manage.py makemigrations +python manage.py migrate +``` + +### 10. Collect static files + +```powershell +python manage.py collectstatic --noinput +``` + +### 11. Run the development server + +```powershell +python manage.py runserver +``` + +Open the site at: + +```text +http://127.0.0.1:8000/ +``` + +## Contribution + +- Create a separate branch for each change: `git checkout -b feature/your-change` +- Keep pull requests focused and small +- Add a clear title and description for each PR +- Include testing or setup steps in the PR description +- Request review from the team or maintainer + +## Notes + +- Environment variables should be stored in `website/.env` and kept private. +- If `DATABASE_URL` is not provided, the project defaults to SQLite at `website/db.sqlite3`. +- If using SQLite locally, database host/user/password settings are not required. +- The frontend repository is separate: `https://github.com/RECursion-NITD/RECursionNITD-Frontend` 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..45a90710 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/forms.py b/website/blog/forms.py index 89326632..b94f7b88 100644 --- a/website/blog/forms.py +++ b/website/blog/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.forms import UserCreationForm from markdownx.fields import MarkdownxFormField from markdownx.utils import markdownify +from utils.content_filter import sanitize_markdown class Postform(forms.ModelForm): title = models.CharField(max_length=100) @@ -16,7 +17,7 @@ def clean_description(self): data = self.cleaned_data['description'] if len(data)<20: raise forms.ValidationError("Description too Short! Less than 20 words!") - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: @@ -42,7 +43,7 @@ class Replyform(forms.ModelForm): def clean_description(self): data = self.cleaned_data['description'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data @@ -55,7 +56,7 @@ class Commentform(forms.ModelForm): def clean_body(self): data = self.cleaned_data['body'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: @@ -67,7 +68,7 @@ class Comment_Replyform(forms.ModelForm): def clean_body(self): data = self.cleaned_data['body'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: 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/models.py b/website/events/models.py index 1922afad..4faf93a5 100644 --- a/website/events/models.py +++ b/website/events/models.py @@ -20,7 +20,8 @@ class Events(models.Model): # Create a property that returns the markdown instead @property def formatted_markdown(self): - return markdownify(self.description) + from utils.content_filter import sanitize_markdown + return sanitize_markdown(markdownify(self.description)) def __str__(self): return self.title 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/forms.py b/website/events_calendar/forms.py index bb669fec..75c8be4a 100644 --- a/website/events_calendar/forms.py +++ b/website/events_calendar/forms.py @@ -39,8 +39,10 @@ def clean_end_time(self): return final_dt def clean_description(self): + from markdownx.utils import markdownify + from utils.content_filter import sanitize_markdown data = self.cleaned_data['description'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data def clean(self): diff --git a/website/events_calendar/models.py b/website/events_calendar/models.py index d62cf242..02f0de8b 100644 --- a/website/events_calendar/models.py +++ b/website/events_calendar/models.py @@ -1,13 +1,17 @@ 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 +from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile 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,25 +28,45 @@ 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) # Create a property that returns the markdown instead @property def formatted_markdown(self): - return markdownify(self.description) - + from utils.content_filter import sanitize_markdown + return sanitize_markdown(markdownify(self.description)) + def __str__(self): return self.event_type + " - " + self.title + + def save(self, *args, **kwargs): + # If a brand-new image is being uploaded, delete the old one from disk first. + # Event images are named by title (e.g. Introduction_to_STL.png). Without this + # deletion, every admin save would create Introduction_to_STL_XXXXXXX.png variants. + if self.image and isinstance(self.image, (InMemoryUploadedFile, TemporaryUploadedFile)): + try: + old = Events_Calendar.objects.get(pk=self.pk) + if old.image: + storage = old.image.storage + if storage.exists(old.image.name): + storage.delete(old.image.name) + except Events_Calendar.DoesNotExist: + pass # New event — no old image to remove. + super(Events_Calendar, self).save(*args, **kwargs) + + 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/forms.py b/website/forum/forms.py index 183fce6e..1bfd2569 100644 --- a/website/forum/forms.py +++ b/website/forum/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.forms import UserCreationForm from markdownx.fields import MarkdownxFormField from markdownx.utils import markdownify +from utils.content_filter import sanitize_markdown class Questionform(forms.ModelForm): title = models.CharField(max_length=100) @@ -18,7 +19,7 @@ def clean_description(self): print(len(data)<20) if len(data)<20: raise forms.ValidationError("Description too Short! Less than 20 words!") - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: @@ -44,7 +45,7 @@ class Answerform(forms.ModelForm): def clean_description(self): data = self.cleaned_data['description'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data @@ -57,7 +58,7 @@ class Commentform(forms.ModelForm): def clean_body(self): data = self.cleaned_data['body'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: @@ -69,7 +70,7 @@ class Comment_Answerform(forms.ModelForm): def clean_body(self): data = self.cleaned_data['body'] - data = markdownify(data) + data = sanitize_markdown(markdownify(data)) return data class Meta: 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..fe528626 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]) @@ -45,8 +50,9 @@ class Experiences(models.Model): @property def formatted_markdown(self): - return markdownify(self.interview_Questions) - + from utils.content_filter import sanitize_markdown + return sanitize_markdown(markdownify(self.interview_Questions)) + def __str__(self): return str(self.company) + " " + str(self.user) @@ -54,6 +60,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 +70,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 +86,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/models.py b/website/team/models.py index b88f477a..b2a12cf8 100644 --- a/website/team/models.py +++ b/website/team/models.py @@ -3,6 +3,7 @@ import os from django.db import models from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile def current_year(): return datetime.date.today().year @@ -33,6 +34,21 @@ class Members(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): + # If a brand-new image is being uploaded, delete the old one from disk first. + # This prevents Django from appending a random suffix (e.g. _XXXXXXX) to the + # filename because the old file at that path already existed. + if isinstance(self.image, (InMemoryUploadedFile, TemporaryUploadedFile)): + try: + old = Members.objects.get(pk=self.pk) + if old.image: + storage = old.image.storage + if storage.exists(old.image.name): + storage.delete(old.image.name) + except Members.DoesNotExist: + pass # New member record — no old image to remove. + super(Members, self).save(*args, **kwargs) + def get_cname(self): class_name = "Member" return class_name 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..94916905 --- /dev/null +++ b/website/user_profile/api/views.py @@ -0,0 +1,369 @@ +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 +import os + + + +# 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 _allowed_client_ids(self): + allowed_client_ids = { + getattr(settings, "GOOGLE_ANDROID_CLIENT_ID", None), + getattr(settings, "GOOGLE_WEB_CLIENT_ID", None), + getattr(settings, "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", None), + } + return {client_id for client_id in allowed_client_ids if client_id} + + def _is_allowed_email_domain(self, email): + domains = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS", "") + allowed_domains = [domain.strip().lower() for domain in domains.split(",") if domain.strip()] + if not allowed_domains: + return True + + email_domain = email.split("@")[-1].lower() if email and "@" in email else "" + return email_domain in allowed_domains + + 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("access_token") or request.data.get("token") + id_token = request.data.get("id_token") + client_id = request.data.get("client_id") + allowed_client_ids = self._allowed_client_ids() + + try: + if access_token: + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + token_info = requests.get( + "https://www.googleapis.com/oauth2/v2/tokeninfo", + params={"access_token": access_token}, + timeout=10, + ).json() + + if 'error' in token_info: + print(f"Google API Error: {token_info}") + return Response(data={'response': 'Invalid token from Google'}, status=400) + + issued_to = token_info.get('issued_to') + if client_id and client_id not in allowed_client_ids: + return Response(data={'response': 'Unauthorized client_id'}, status=401) + + # Validate token's issued_to (must match one of allowed client IDs) + if not issued_to or issued_to not in allowed_client_ids: + print(f"Client ID mismatch. Token issued_to={issued_to}. Allowed: {allowed_client_ids}") + return Response(data={'response': 'Unauthorized - Client ID mismatch'}, status=401) + + USER_INFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + user_info = requests.get( + USER_INFO_URL, + headers=headers, + timeout=10, + ).json() + + if 'error' in user_info: + return Response(data={'response': 'Failed to fetch user information'}, status=400) + + elif id_token: + token_info = requests.get( + "https://oauth2.googleapis.com/tokeninfo", + params={"id_token": id_token}, + timeout=10, + ).json() + + if 'error' in token_info: + error_desc = token_info.get('error_description', token_info.get('error', 'Unknown error')) + return Response(data={'response': f'Invalid id token: {error_desc}'}, status=400) + + # Check both 'azp' (Authorized Party - who token was issued to) + # and 'aud' (Audience - who it's for) since Flutter sends Android OAuth tokens + azp = token_info.get('azp') + aud = token_info.get('aud') + + # Validate provided client_id if given + if client_id and client_id not in allowed_client_ids: + print(f"Provided client_id not in allowed list. Got: {client_id}") + return Response(data={'response': 'Unauthorized client_id'}, status=401) + + # Accept if EITHER azp OR aud is in allowed list + if azp not in allowed_client_ids and aud not in allowed_client_ids: + print(f"Client ID mismatch. Token aud={aud}, azp={azp}. Allowed: {allowed_client_ids}") + return Response(data={'response': f'Unauthorized - Client ID mismatch'}, status=401) + + user_info = { + 'email': token_info.get('email'), + 'name': token_info.get('name', ''), + 'given_name': token_info.get('given_name', ''), + 'picture': token_info.get('picture'), + } + + else: + return Response(data={'response': 'Token missing'}, status=400) + + email = user_info.get('email') + if not email: + return Response(data={'response': 'Email missing from Google token'}, status=400) + + if not self._is_allowed_email_domain(email): + return Response(data={'response': 'Unauthorized email domain'}, status=401) + + try: + try: + user = User.objects.get(email=email) + created = False + except User.DoesNotExist: + base_username = email.split('@')[0] + username = base_username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base_username}{counter}" + counter += 1 + + user = User.objects.create( + username=username, + email=email, + first_name=user_info.get('name', ''), + last_name=user_info.get('given_name', '') + ) + created = True + + if not created: + user.first_name = user_info.get('name', user.first_name) + user.last_name = user_info.get('given_name', user.last_name) + user.save(update_fields=['first_name', 'last_name']) + + 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, timeout=10) + 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) + 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): + permission_classes = (IsAdminUser,) + 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..3a5df6a4 100644 --- a/website/user_profile/models.py +++ b/website/user_profile/models.py @@ -10,18 +10,23 @@ 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 +from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile +from django_prometheus.models import ExportModelOperationsMixin + import sys -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 +35,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 +44,71 @@ 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): + # Only resize/re-encode when a BRAND NEW image is being uploaded. + # InMemoryUploadedFile = file uploaded via a form (small, held in RAM). + # TemporaryUploadedFile = file uploaded via a form (large, spooled to /tmp). + # An existing ImageFieldFile (already on disk) must NOT be re-processed — that + # was the root cause of all the _XXXXXXX suffix duplicates. + is_new_upload = isinstance(self.image, (InMemoryUploadedFile, TemporaryUploadedFile)) + + if self.image and is_new_upload: + # Step 1: Delete the old image file from disk before writing the new one. + # This prevents Django from appending a random suffix because it found an + # existing file at the same target path. + try: + old = Profile.objects.get(pk=self.pk) + if old.image: + storage = old.image.storage + if storage.exists(old.image.name): + storage.delete(old.image.name) + except Profile.DoesNotExist: + pass # Brand-new profile — no old image to clean up. + + # Step 2: Resize the new upload to 100x100 pixels. + img = Image.open(self.image) + output = BytesIO() + img = img.resize((100, 100)) + img.save(output, format='PNG', quality=100) + output.seek(0) + + # Step 3: Wrap the resized bytes in an InMemoryUploadedFile. + # Pass the STABLE filename (username.png) so content_file_name returns + # images/username.png — and since we just deleted that file, Django will + # write it cleanly with NO random suffix. + self.image = InMemoryUploadedFile( + output, 'ImageField', f"{self.user.username}.png", + 'image/png', sys.getsizeof(output), None + ) + + super(Profile, self).save(*args, **kwargs) + + 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: + # A new User was just registered — create their Profile. + # Profile.objects.create() already calls save() internally, so we stop here. Profile.objects.create(user=instance) - instance.profile.save() + else: + # An existing User was updated (password reset, email confirm, admin edit, etc.). + # We still need to save the profile to persist any in-memory field changes + # (e.g. profile.email_confirmed = True set before user.save() was called). + # This is now SAFE because Profile.save() only re-processes genuinely new uploads. + try: + instance.profile.save() + except Profile.DoesNotExist: + # Edge case: user exists but has no profile yet — create one. + Profile.objects.create(user=instance) 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..10367751 --- /dev/null +++ b/website/utils/content_filter.py @@ -0,0 +1,120 @@ +import os +import requests +import json +import bleach + +def sanitize_markdown(html): + allowed_tags = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'b', + 'em', 'i', 'u', 'a', 'ul', 'ol', 'li', 'blockquote', 'pre', + 'code', 'hr', 'img', 'table', 'thead', 'tbody', 'tr', 'th', + 'td', 'span', 'div', 'del', 'sup', 'sub' + ] + allowed_attributes = { + '*': ['class', 'style', 'id'], + 'a': ['href', 'title', 'target'], + 'img': ['src', 'alt', 'title', 'width', 'height'] + } + from bleach.css_sanitizer import CSSSanitizer + allowed_styles = [ + 'color', 'background-color', 'font-size', 'text-align', 'font-weight', 'font-style' + ] + css_sanitizer = CSSSanitizer(allowed_css_properties=allowed_styles) + return bleach.clean(html, tags=allowed_tags, attributes=allowed_attributes, css_sanitizer=css_sanitizer) + +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..2e01edac 100644 --- a/website/website/settings.py +++ b/website/website/settings.py @@ -8,24 +8,57 @@ """ import os -from decouple import config +from dotenv import load_dotenv + +# Always load workspace root .env and override pre-existing OS env vars. +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(CURRENT_DIR) +REPO_ROOT = os.path.dirname(PROJECT_DIR) +load_dotenv(dotenv_path=os.path.join(REPO_ROOT, '.env'), override=True) +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) + +GOOGLE_ANDROID_CLIENT_ID = config('GOOGLE_ANDROID_CLIENT_ID', None) +GOOGLE_WEB_CLIENT_ID = config('GOOGLE_WEB_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 = ['*'] +# ngrok/proxy support for correct absolute URL generation during OAuth callbacks. +USE_X_FORWARDED_HOST = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SOCIAL_AUTH_REDIRECT_IS_HTTPS = True +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') + +# CORS_ALLOW_ALL_ORIGINS = True + +CORS_ALLOWED_ORIGINS = [ + FRONTEND_BASE_URL +] + +CORS_ALLOW_CREDENTIALS = True + # Application definition INSTALLED_APPS = [ @@ -46,23 +79,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 +126,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 +211,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,10 +233,16 @@ 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') +# Whitelist domains for OAuth +SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = [ + 'recursionnitd.in', + 'localhost', + '127.0.0.1', + 'gmail.com' +] # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ @@ -163,20 +257,43 @@ 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='') + +TRIAL_REC_MAIL = 'jiwegaw290@randrai.com' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +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