diff --git a/core/admin.py b/core/admin.py index 2851387..88201ba 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import FAQ -from .models import Contact, SuccessStory, StoryReaction, Question, Answer, QuestionUpvote, AnswerUpvote,Goal,Milestone,StudySession,Achievement,UserStats,WeeklyGoal +from .models import Contact, SuccessStory, StoryReaction, Question, Answer, QuestionUpvote, AnswerUpvote,Goal,Milestone,StudySession,Achievement,UserStats,WeeklyGoal,StudyProfile, StudyPartnerRequest, StudyPartnership,PartnerStudySession @admin.register(Contact) class ContactAdmin(admin.ModelAdmin): @@ -289,4 +289,81 @@ def progress_percentage(self, obj): ('Timestamps', { 'fields': ('created_at',) }), + ) +@admin.register(StudyProfile) +class StudyProfileAdmin(admin.ModelAdmin): + list_display = ('user', 'study_level', 'timezone', 'is_available', 'created_at') + list_filter = ('study_level', 'timezone', 'is_available', 'created_at') + search_fields = ('user__username', 'user__first_name', 'user__last_name', 'subjects', 'bio') + readonly_fields = ('created_at', 'updated_at') + list_editable = ('is_available',) + ordering = ('-created_at',) + + fieldsets = ( + ('User Information', { + 'fields': ('user', 'bio', 'is_available') + }), + ('Study Preferences', { + 'fields': ('subjects', 'study_level', 'study_goals') + }), + ('Availability & Contact', { + 'fields': ('preferred_study_times', 'timezone', 'languages', 'contact_preference', 'contact_info') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + +@admin.register(StudyPartnerRequest) +class StudyPartnerRequestAdmin(admin.ModelAdmin): + list_display = ('from_user', 'to_user', 'status', 'created_at', 'responded_at') + list_filter = ('status', 'created_at') + search_fields = ('from_user__username', 'to_user__username', 'message') + readonly_fields = ('created_at',) + ordering = ('-created_at',) + + fieldsets = ( + ('Request Information', { + 'fields': ('from_user', 'to_user', 'message') + }), + ('Status', { + 'fields': ('status', 'responded_at') + }), + ('Timestamp', { + 'fields': ('created_at',) + }), + ) + +@admin.register(StudyPartnership) +class StudyPartnershipAdmin(admin.ModelAdmin): + list_display = ('user1', 'user2', 'is_active', 'total_sessions', 'last_session', 'created_at') + list_filter = ('is_active', 'created_at') + search_fields = ('user1__username', 'user2__username') + readonly_fields = ('created_at', 'total_sessions', 'last_session') + list_editable = ('is_active',) + ordering = ('-created_at',) + +@admin.register(PartnerStudySession) +class PartnerStudySessionAdmin(admin.ModelAdmin): + list_display = ('title', 'partnership', 'subject', 'scheduled_time', 'duration_hours', 'is_completed', 'created_by') + list_filter = ('subject', 'is_completed', 'scheduled_time', 'created_at') + search_fields = ('title', 'description', 'subject', 'partnership__user1__username', 'partnership__user2__username') + readonly_fields = ('created_at',) + list_editable = ('is_completed',) + ordering = ('-scheduled_time',) + + fieldsets = ( + ('Session Information', { + 'fields': ('title', 'description', 'subject', 'partnership', 'created_by') + }), + ('Schedule', { + 'fields': ('scheduled_time', 'duration_hours') + }), + ('Status', { + 'fields': ('is_completed', 'notes') + }), + ('Timestamp', { + 'fields': ('created_at',) + }), ) \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index d742c56..f0a4e76 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Contact, SuccessStory, Question, Answer,Goal, Milestone, StudySession, WeeklyGoal +from .models import Contact, SuccessStory, Question, Answer,Goal, Milestone, StudySession, WeeklyGoal,StudyProfile, StudyPartnerRequest,PartnerStudySession from .models import FAQ from django.utils import timezone from datetime import datetime, timedelta @@ -297,4 +297,233 @@ class Meta: 'class': 'form-input', 'required': True }), - } \ No newline at end of file + } + +class StudyProfileForm(forms.ModelForm): + class Meta: + model = StudyProfile + fields = [ + 'bio', 'subjects', 'study_level', 'preferred_study_times', + 'timezone', 'languages', 'study_goals', 'contact_preference', + 'contact_info', 'is_available' + ] + widgets = { + 'bio': forms.Textarea(attrs={ + 'class': 'form-textarea', + 'placeholder': 'Tell others about your study goals, interests, and what you hope to achieve through collaboration...', + 'rows': 4 + }), + 'subjects': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'e.g., Mathematics, Physics, Computer Science, Biology' + }), + 'study_level': forms.Select(attrs={ + 'class': 'form-input' + }), + 'preferred_study_times': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'e.g., morning, evening, weekend' + }), + 'timezone': forms.Select(attrs={ + 'class': 'form-input' + }), + 'languages': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'e.g., English, Spanish, French' + }), + 'study_goals': forms.Textarea(attrs={ + 'class': 'form-textarea', + 'placeholder': 'What are your current study goals? Exams, projects, skill development...', + 'rows': 3 + }), + 'contact_preference': forms.Select(attrs={ + 'class': 'form-input' + }), + 'contact_info': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'Your email, Discord username, etc. (optional)' + }), + 'is_available': forms.CheckboxInput(attrs={ + 'class': 'form-checkbox' + }) + } + + def clean_subjects(self): + subjects = self.cleaned_data.get('subjects', '') + if subjects: + subject_list = [subject.strip() for subject in subjects.split(',') if subject.strip()] + if len(subject_list) > 10: + raise forms.ValidationError("Maximum 10 subjects allowed.") + return ', '.join(subject_list[:10]) + return subjects + + def clean_preferred_study_times(self): + times = self.cleaned_data.get('preferred_study_times', '') + if times: + time_list = [time.strip() for time in times.split(',') if time.strip()] + return ', '.join(time_list[:5]) # Max 5 time preferences + return times + + def clean_languages(self): + languages = self.cleaned_data.get('languages', '') + if languages: + lang_list = [lang.strip() for lang in languages.split(',') if lang.strip()] + return ', '.join(lang_list[:5]) # Max 5 languages + return languages + + +class StudyPartnerRequestForm(forms.ModelForm): + class Meta: + model = StudyPartnerRequest + fields = ['message'] + widgets = { + 'message': forms.Textarea(attrs={ + 'class': 'form-textarea', + 'placeholder': 'Introduce yourself and explain why you\'d like to study together. What subjects are you interested in? What are your goals?', + 'rows': 4, + 'required': True + }) + } + + def clean_message(self): + message = self.cleaned_data.get('message', '') + if len(message.strip()) < 20: + raise forms.ValidationError("Please provide a more detailed message (at least 20 characters).") + if len(message) > 300: + raise forms.ValidationError("Message is too long (maximum 300 characters).") + return message + + +class PartnerStudySessionForm(forms.ModelForm): + class Meta: + model = PartnerStudySession + fields = ['title', 'description', 'subject', 'scheduled_time', 'duration_hours'] + widgets = { + 'title': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'e.g., Calculus Study Session, Physics Problem Solving', + 'required': True + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-textarea', + 'placeholder': 'What will you focus on during this session?', + 'rows': 3 + }), + 'subject': forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'e.g., Mathematics, Physics, Computer Science', + 'required': True + }), + 'scheduled_time': forms.DateTimeInput(attrs={ + 'class': 'form-input', + 'type': 'datetime-local', + 'required': True + }), + 'duration_hours': forms.NumberInput(attrs={ + 'class': 'form-input', + 'min': '0.5', + 'max': '8.0', + 'step': '0.5', + 'value': '2.0' + }) + } + + def clean_scheduled_time(self): + scheduled_time = self.cleaned_data.get('scheduled_time') + if scheduled_time: + from django.utils import timezone + if scheduled_time <= timezone.now(): + raise forms.ValidationError("Session must be scheduled for a future date and time.") + return scheduled_time + + def clean_duration_hours(self): + duration = self.cleaned_data.get('duration_hours') + if duration and (duration < 0.5 or duration > 8.0): + raise forms.ValidationError("Session duration must be between 0.5 and 8 hours.") + return duration + + +class StudyPartnerSearchForm(forms.Form): + SUBJECT_CHOICES = [ + ('', 'All Subjects'), + ('math', 'Mathematics'), + ('science', 'Science'), + ('physics', 'Physics'), + ('chemistry', 'Chemistry'), + ('biology', 'Biology'), + ('computer_science', 'Computer Science'), + ('programming', 'Programming'), + ('english', 'English'), + ('history', 'History'), + ('geography', 'Geography'), + ('economics', 'Economics'), + ('psychology', 'Psychology'), + ('philosophy', 'Philosophy'), + ('engineering', 'Engineering'), + ('other', 'Other'), + ] + + LEVEL_CHOICES = [ + ('', 'All Levels'), + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advanced', 'Advanced'), + ('expert', 'Expert'), + ] + + TIMEZONE_CHOICES = [ + ('', 'All Timezones'), + ('UTC-12', 'UTC-12 (Baker Island)'), + ('UTC-11', 'UTC-11 (American Samoa)'), + ('UTC-10', 'UTC-10 (Hawaii)'), + ('UTC-9', 'UTC-9 (Alaska)'), + ('UTC-8', 'UTC-8 (PST)'), + ('UTC-7', 'UTC-7 (MST)'), + ('UTC-6', 'UTC-6 (CST)'), + ('UTC-5', 'UTC-5 (EST)'), + ('UTC-4', 'UTC-4 (AST)'), + ('UTC-3', 'UTC-3 (Brazil)'), + ('UTC-2', 'UTC-2 (Mid-Atlantic)'), + ('UTC-1', 'UTC-1 (Azores)'), + ('UTC+0', 'UTC+0 (GMT/London)'), + ('UTC+1', 'UTC+1 (CET/Paris)'), + ('UTC+2', 'UTC+2 (EET/Cairo)'), + ('UTC+3', 'UTC+3 (Moscow)'), + ('UTC+4', 'UTC+4 (Dubai)'), + ('UTC+5', 'UTC+5 (Pakistan)'), + ('UTC+5:30', 'UTC+5:30 (India)'), + ('UTC+6', 'UTC+6 (Bangladesh)'), + ('UTC+7', 'UTC+7 (Thailand)'), + ('UTC+8', 'UTC+8 (China/Singapore)'), + ('UTC+9', 'UTC+9 (Japan/Korea)'), + ('UTC+10', 'UTC+10 (Australia East)'), + ('UTC+11', 'UTC+11 (Solomon Islands)'), + ('UTC+12', 'UTC+12 (New Zealand)'), + ] + + subject = forms.ChoiceField( + choices=SUBJECT_CHOICES, + required=False, + widget=forms.Select(attrs={'class': 'form-input'}) + ) + + study_level = forms.ChoiceField( + choices=LEVEL_CHOICES, + required=False, + widget=forms.Select(attrs={'class': 'form-input'}) + ) + + timezone = forms.ChoiceField( + choices=TIMEZONE_CHOICES, + required=False, + widget=forms.Select(attrs={'class': 'form-input'}) + ) + + search_query = forms.CharField( + max_length=100, + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-input', + 'placeholder': 'Search by name, bio, subjects, or goals...' + }) + ) \ No newline at end of file diff --git a/core/migrations/0004_studypartnership_partnerstudysession_studyprofile_and_more.py b/core/migrations/0004_studypartnership_partnerstudysession_studyprofile_and_more.py new file mode 100644 index 0000000..320608f --- /dev/null +++ b/core/migrations/0004_studypartnership_partnerstudysession_studyprofile_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 5.2.4 on 2025-09-10 19:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_goal_milestone_studysession_userstats_achievement_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StudyPartnership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('total_sessions', models.PositiveIntegerField(default=0)), + ('last_session', models.DateTimeField(blank=True, null=True)), + ('user1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partnerships_as_user1', to=settings.AUTH_USER_MODEL)), + ('user2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='partnerships_as_user2', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('user1', 'user2')}, + }, + ), + migrations.CreateModel( + name='PartnerStudySession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('subject', models.CharField(max_length=100)), + ('scheduled_time', models.DateTimeField()), + ('duration_hours', models.DecimalField(decimal_places=1, default=1.0, max_digits=3)), + ('is_completed', models.BooleanField(default=False)), + ('notes', models.TextField(blank=True, help_text='Session notes and outcomes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('partnership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='core.studypartnership')), + ], + options={ + 'ordering': ['-scheduled_time'], + }, + ), + migrations.CreateModel( + name='StudyProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bio', models.TextField(blank=True, help_text='Tell others about your study goals and interests', max_length=500)), + ('subjects', models.CharField(help_text="Comma-separated subjects you're studying", max_length=500)), + ('study_level', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'), ('expert', 'Expert')], default='intermediate', max_length=20)), + ('preferred_study_times', models.CharField(help_text='Comma-separated preferred times', max_length=100)), + ('timezone', models.CharField(choices=[('UTC-12', 'UTC-12 (Baker Island)'), ('UTC-11', 'UTC-11 (American Samoa)'), ('UTC-10', 'UTC-10 (Hawaii)'), ('UTC-9', 'UTC-9 (Alaska)'), ('UTC-8', 'UTC-8 (PST)'), ('UTC-7', 'UTC-7 (MST)'), ('UTC-6', 'UTC-6 (CST)'), ('UTC-5', 'UTC-5 (EST)'), ('UTC-4', 'UTC-4 (AST)'), ('UTC-3', 'UTC-3 (Brazil)'), ('UTC-2', 'UTC-2 (Mid-Atlantic)'), ('UTC-1', 'UTC-1 (Azores)'), ('UTC+0', 'UTC+0 (GMT/London)'), ('UTC+1', 'UTC+1 (CET/Paris)'), ('UTC+2', 'UTC+2 (EET/Cairo)'), ('UTC+3', 'UTC+3 (Moscow)'), ('UTC+4', 'UTC+4 (Dubai)'), ('UTC+5', 'UTC+5 (Pakistan)'), ('UTC+5:30', 'UTC+5:30 (India)'), ('UTC+6', 'UTC+6 (Bangladesh)'), ('UTC+7', 'UTC+7 (Thailand)'), ('UTC+8', 'UTC+8 (China/Singapore)'), ('UTC+9', 'UTC+9 (Japan/Korea)'), ('UTC+10', 'UTC+10 (Australia East)'), ('UTC+11', 'UTC+11 (Solomon Islands)'), ('UTC+12', 'UTC+12 (New Zealand)')], default='UTC+0', max_length=10)), + ('languages', models.CharField(default='English', help_text='Languages you can communicate in', max_length=200)), + ('study_goals', models.TextField(blank=True, help_text='What are you trying to achieve?', max_length=300)), + ('contact_preference', models.CharField(choices=[('platform', 'Through Platform Only'), ('email', 'Email'), ('discord', 'Discord'), ('zoom', 'Zoom')], default='platform', max_length=50)), + ('contact_info', models.CharField(blank=True, help_text='Your contact information (optional)', max_length=100)), + ('is_available', models.BooleanField(default=True, help_text='Are you currently looking for study partners?')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='study_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StudyPartnerRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(help_text="Introduce yourself and explain why you'd like to study together", max_length=300)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('declined', 'Declined')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('responded_at', models.DateTimeField(blank=True, null=True)), + ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_requests', to=settings.AUTH_USER_MODEL)), + ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_requests', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('from_user', 'to_user')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 61ca22d..15b3ab8 100644 --- a/core/models.py +++ b/core/models.py @@ -431,4 +431,171 @@ def __str__(self): def progress_percentage(self): hours_progress = min((self.actual_study_hours / self.target_study_hours) * 100, 100) if self.target_study_hours > 0 else 100 sessions_progress = min((self.actual_sessions / self.target_sessions) * 100, 100) if self.target_sessions > 0 else 100 - return (hours_progress + sessions_progress) / 2 \ No newline at end of file + return (hours_progress + sessions_progress) / 2 + +class StudyProfile(models.Model): + STUDY_LEVEL_CHOICES = [ + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advanced', 'Advanced'), + ('expert', 'Expert'), + ] + + AVAILABILITY_CHOICES = [ + ('morning', 'Morning (6 AM - 12 PM)'), + ('afternoon', 'Afternoon (12 PM - 6 PM)'), + ('evening', 'Evening (6 PM - 10 PM)'), + ('night', 'Night (10 PM - 2 AM)'), + ] + + TIMEZONE_CHOICES = [ + ('UTC-12', 'UTC-12 (Baker Island)'), + ('UTC-11', 'UTC-11 (American Samoa)'), + ('UTC-10', 'UTC-10 (Hawaii)'), + ('UTC-9', 'UTC-9 (Alaska)'), + ('UTC-8', 'UTC-8 (PST)'), + ('UTC-7', 'UTC-7 (MST)'), + ('UTC-6', 'UTC-6 (CST)'), + ('UTC-5', 'UTC-5 (EST)'), + ('UTC-4', 'UTC-4 (AST)'), + ('UTC-3', 'UTC-3 (Brazil)'), + ('UTC-2', 'UTC-2 (Mid-Atlantic)'), + ('UTC-1', 'UTC-1 (Azores)'), + ('UTC+0', 'UTC+0 (GMT/London)'), + ('UTC+1', 'UTC+1 (CET/Paris)'), + ('UTC+2', 'UTC+2 (EET/Cairo)'), + ('UTC+3', 'UTC+3 (Moscow)'), + ('UTC+4', 'UTC+4 (Dubai)'), + ('UTC+5', 'UTC+5 (Pakistan)'), + ('UTC+5:30', 'UTC+5:30 (India)'), + ('UTC+6', 'UTC+6 (Bangladesh)'), + ('UTC+7', 'UTC+7 (Thailand)'), + ('UTC+8', 'UTC+8 (China/Singapore)'), + ('UTC+9', 'UTC+9 (Japan/Korea)'), + ('UTC+10', 'UTC+10 (Australia East)'), + ('UTC+11', 'UTC+11 (Solomon Islands)'), + ('UTC+12', 'UTC+12 (New Zealand)'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='study_profile') + bio = models.TextField(max_length=500, blank=True, help_text="Tell others about your study goals and interests") + subjects = models.CharField(max_length=500, help_text="Comma-separated subjects you're studying") + study_level = models.CharField(max_length=20, choices=STUDY_LEVEL_CHOICES, default='intermediate') + preferred_study_times = models.CharField(max_length=100, help_text="Comma-separated preferred times") + timezone = models.CharField(max_length=10, choices=TIMEZONE_CHOICES, default='UTC+0') + languages = models.CharField(max_length=200, default='English', help_text="Languages you can communicate in") + study_goals = models.TextField(max_length=300, blank=True, help_text="What are you trying to achieve?") + contact_preference = models.CharField( + max_length=50, + choices=[ + ('platform', 'Through Platform Only'), + ('email', 'Email'), + ('discord', 'Discord'), + ('zoom', 'Zoom'), + ], + default='platform' + ) + contact_info = models.CharField(max_length=100, blank=True, help_text="Your contact information (optional)") + is_available = models.BooleanField(default=True, help_text="Are you currently looking for study partners?") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.user.username}'s Study Profile" + + def get_subjects_list(self): + """Return subjects as a list""" + if self.subjects: + return [subject.strip() for subject in self.subjects.split(',') if subject.strip()] + return [] + + def get_study_times_list(self): + """Return preferred study times as a list""" + if self.preferred_study_times: + return [time.strip() for time in self.preferred_study_times.split(',') if time.strip()] + return [] + + def get_languages_list(self): + """Return languages as a list""" + if self.languages: + return [lang.strip() for lang in self.languages.split(',') if lang.strip()] + return [] + + def get_user_initials(self): + """Get user initials for avatar""" + if self.user.first_name and self.user.last_name: + return f"{self.user.first_name[0]}{self.user.last_name[0]}".upper() + elif self.user.first_name: + return self.user.first_name[0].upper() + return self.user.username[0].upper() if self.user.username else "U" + + def get_display_name(self): + """Get display name for user""" + if self.user.first_name and self.user.last_name: + return f"{self.user.first_name} {self.user.last_name}" + elif self.user.first_name: + return self.user.first_name + return self.user.username + + +class StudyPartnerRequest(models.Model): + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('declined', 'Declined'), + ] + + from_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_requests') + to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_requests') + message = models.TextField(max_length=300, help_text="Introduce yourself and explain why you'd like to study together") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + created_at = models.DateTimeField(auto_now_add=True) + responded_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('from_user', 'to_user') + ordering = ['-created_at'] + + def __str__(self): + return f"{self.from_user.username} -> {self.to_user.username} ({self.status})" + + +class StudyPartnership(models.Model): + user1 = models.ForeignKey(User, on_delete=models.CASCADE, related_name='partnerships_as_user1') + user2 = models.ForeignKey(User, on_delete=models.CASCADE, related_name='partnerships_as_user2') + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + # Study session tracking + total_sessions = models.PositiveIntegerField(default=0) + last_session = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('user1', 'user2') + ordering = ['-created_at'] + + def __str__(self): + return f"{self.user1.username} <-> {self.user2.username}" + + def get_partner(self, current_user): + """Get the other user in the partnership""" + return self.user2 if self.user1 == current_user else self.user1 + + +class PartnerStudySession(models.Model): + partnership = models.ForeignKey(StudyPartnership, on_delete=models.CASCADE, related_name='sessions') + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + subject = models.CharField(max_length=100) + scheduled_time = models.DateTimeField() + duration_hours = models.DecimalField(max_digits=3, decimal_places=1, default=1.0) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + is_completed = models.BooleanField(default=False) + notes = models.TextField(blank=True, help_text="Session notes and outcomes") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-scheduled_time'] + + def __str__(self): + return f"{self.title} - {self.scheduled_time.strftime('%Y-%m-%d %H:%M')}" \ No newline at end of file diff --git a/core/static/css/index.css b/core/static/css/index.css index c68dea6..e823947 100644 --- a/core/static/css/index.css +++ b/core/static/css/index.css @@ -37,6 +37,7 @@ body { font-weight: 900; background: linear-gradient(90deg, #00bfff, #ffffff); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; text-transform: uppercase; margin-bottom: 1rem; diff --git a/core/static/js/study_partners.js b/core/static/js/study_partners.js new file mode 100644 index 0000000..cdc309a --- /dev/null +++ b/core/static/js/study_partners.js @@ -0,0 +1,459 @@ +// Create this file: core/static/js/study_partners.js + +// Study Partners functionality +var selectedPartnership = null; + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + initializeStudyPartners(); +}); + +function initializeStudyPartners() { + // Initialize schedule form if it exists + var scheduleForm = document.getElementById('scheduleForm'); + if (scheduleForm) { + scheduleForm.addEventListener('submit', handleScheduleFormSubmit); + } + + // Set up modal close handlers + window.onclick = function(event) { + var scheduleModal = document.getElementById('scheduleModal'); + if (event.target === scheduleModal) { + closeScheduleModal(); + } + + var sessionsModal = document.getElementById('sessionsModal'); + if (event.target === sessionsModal) { + closeSessionsModal(); + } + }; +} + +// Open schedule modal +function openScheduleModal(partnershipId, partnerName) { + selectedPartnership = partnershipId; + + // Set minimum date to current date/time + var now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + var minDateTime = now.toISOString().slice(0, 16); + var timeInput = document.querySelector('input[name="scheduled_time"]'); + if (timeInput) { + timeInput.min = minDateTime; + } + + // Update partner info + var partnerInfo = document.getElementById('partnerInfo'); + if (partnerInfo) { + partnerInfo.innerHTML = createPartnerInfoHTML(partnerName); + } + + // Clear form + var form = document.getElementById('scheduleForm'); + if (form) { + form.reset(); + } + + // Show modal + var modal = document.getElementById('scheduleModal'); + if (modal) { + modal.style.display = 'block'; + } +} + +// Create partner info HTML +function createPartnerInfoHTML(partnerName) { + var div = document.createElement('div'); + div.style.cssText = 'display: flex; align-items: center; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;'; + + var innerDiv = document.createElement('div'); + innerDiv.style.cssText = 'color: white; font-weight: 600;'; + + var icon = document.createElement('i'); + icon.className = 'fas fa-calendar-plus'; + icon.style.cssText = 'color: #3b82f6; margin-right: 0.5rem;'; + + innerDiv.appendChild(icon); + innerDiv.appendChild(document.createTextNode('Scheduling session with ' + partnerName)); + div.appendChild(innerDiv); + + return div.outerHTML; +} + +// Close schedule modal +function closeScheduleModal() { + var modal = document.getElementById('scheduleModal'); + if (modal) { + modal.style.display = 'none'; + } + selectedPartnership = null; +} + +// Handle schedule form submission +function handleScheduleFormSubmit(e) { + e.preventDefault(); + + if (!selectedPartnership) return; + + var form = e.target; + var formData = new FormData(form); + var submitBtn = form.querySelector('button[type="submit"]'); + var originalText = submitBtn.innerHTML; + + // Validate scheduled time + var scheduledTime = new Date(formData.get('scheduled_time')); + var now = new Date(); + + if (scheduledTime <= now) { + showNotification('Please select a future date and time', 'error'); + return; + } + + submitBtn.innerHTML = 'Scheduling...'; + submitBtn.disabled = true; + + var sessionData = { + partnership_id: selectedPartnership, + title: formData.get('title'), + subject: formData.get('subject'), + scheduled_time: formData.get('scheduled_time'), + duration_hours: formData.get('duration_hours'), + description: formData.get('description') + }; + + fetch('/api/schedule-session/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify(sessionData) + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + showNotification('Study session scheduled successfully!', 'success'); + closeScheduleModal(); + setTimeout(function() { + window.location.reload(); + }, 1000); + } else { + var errorMsg = 'Error scheduling session'; + if (data.errors) { + errorMsg += ': ' + JSON.stringify(data.errors); + } + showNotification(errorMsg, 'error'); + } + }) + .catch(function(error) { + console.error('Error:', error); + showNotification('Failed to schedule session', 'error'); + }) + .finally(function() { + submitBtn.innerHTML = originalText; + submitBtn.disabled = false; + }); +} + +// View partnership sessions +function viewPartnershipSessions(partnershipId) { + fetch('/api/partnership-sessions/' + partnershipId + '/') + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + showSessionsModal(data.sessions); + } else { + showNotification('Error loading sessions: ' + data.error, 'error'); + } + }) + .catch(function(error) { + console.error('Error:', error); + showNotification('Failed to load sessions', 'error'); + }); +} + +// Show sessions modal +function showSessionsModal(sessions) { + var modalContainer = document.createElement('div'); + modalContainer.id = 'sessionsModal'; + modalContainer.className = 'modal'; + modalContainer.style.display = 'block'; + + var modalContent = document.createElement('div'); + modalContent.className = 'modal-content'; + + // Header + var header = document.createElement('div'); + header.className = 'modal-header'; + + var title = document.createElement('h2'); + title.className = 'modal-title'; + title.textContent = 'Partnership Sessions'; + + var closeBtn = document.createElement('span'); + closeBtn.className = 'close'; + closeBtn.innerHTML = '×'; + closeBtn.onclick = closeSessionsModal; + + header.appendChild(title); + header.appendChild(closeBtn); + + // Body + var body = document.createElement('div'); + body.className = 'modal-body'; + body.style.cssText = 'max-height: 400px; overflow-y: auto;'; + + if (sessions.length > 0) { + sessions.forEach(function(session) { + var sessionItem = createSessionItem(session); + body.appendChild(sessionItem); + }); + } else { + var emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = '

No sessions yet

Schedule your first study session together!

'; + body.appendChild(emptyState); + } + + modalContent.appendChild(header); + modalContent.appendChild(body); + modalContainer.appendChild(modalContent); + + document.body.appendChild(modalContainer); +} + +// Create session item element +function createSessionItem(session) { + var item = document.createElement('div'); + item.className = 'session-item'; + item.style.marginBottom = '1rem'; + + var scheduledDate = new Date(session.scheduled_time).toLocaleString(); + var statusClass = session.is_completed ? 'status-completed' : 'status-upcoming'; + var statusText = session.is_completed ? 'Completed' : 'Scheduled'; + + var headerDiv = document.createElement('div'); + headerDiv.className = 'session-header'; + + var titleEl = document.createElement('h5'); + titleEl.className = 'session-title'; + titleEl.textContent = session.title; + + var statusEl = document.createElement('span'); + statusEl.className = 'session-status ' + statusClass; + statusEl.textContent = statusText; + + headerDiv.appendChild(titleEl); + headerDiv.appendChild(statusEl); + + var detailsDiv = document.createElement('div'); + detailsDiv.className = 'session-details'; + detailsDiv.innerHTML = + '
' + session.subject + '
' + + '
' + scheduledDate + '
' + + '
' + session.duration_hours + ' hours
' + + '
Created by ' + session.created_by + '
'; + + if (session.description) { + var descDiv = document.createElement('div'); + descDiv.style.cssText = 'margin-top: 0.5rem; font-style: italic;'; + descDiv.textContent = session.description; + detailsDiv.appendChild(descDiv); + } + + if (session.notes) { + var notesDiv = document.createElement('div'); + notesDiv.style.cssText = 'margin-top: 0.5rem; padding: 0.5rem; background: rgba(16,185,129,0.1); border-radius: 4px; color: #10b981;'; + notesDiv.innerHTML = '' + session.notes; + detailsDiv.appendChild(notesDiv); + } + + if (!session.is_completed && new Date(session.scheduled_time) < new Date()) { + var completeBtn = document.createElement('button'); + completeBtn.className = 'btn-schedule'; + completeBtn.style.cssText = 'margin-top: 0.5rem; font-size: 0.8rem; padding: 0.25rem 0.75rem;'; + completeBtn.innerHTML = 'Mark as Completed'; + completeBtn.onclick = function() { markSessionCompleted(session.id); }; + detailsDiv.appendChild(completeBtn); + } + + item.appendChild(headerDiv); + item.appendChild(detailsDiv); + + return item; +} + +// Close sessions modal +function closeSessionsModal() { + var modal = document.getElementById('sessionsModal'); + if (modal) { + modal.remove(); + } +} + +// Mark session as completed +function markSessionCompleted(sessionId) { + var notes = prompt('Add any notes about this session (optional):'); + + fetch('/api/complete-session/' + sessionId + '/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ notes: notes || '' }) + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + showNotification('Session marked as completed!', 'success'); + closeSessionsModal(); + setTimeout(function() { + window.location.reload(); + }, 1000); + } else { + showNotification('Error: ' + data.error, 'error'); + } + }) + .catch(function(error) { + console.error('Error:', error); + showNotification('Failed to complete session', 'error'); + }); +} + +// Confirm end partnership +function confirmEndPartnership(partnershipId) { + if (confirm('Are you sure you want to end this study partnership? This action cannot be undone.')) { + endPartnership(partnershipId); + } +} + +// End partnership +function endPartnership(partnershipId) { + fetch('/api/end-partnership/' + partnershipId + '/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + } + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + showNotification('Partnership ended successfully', 'success'); + var card = document.querySelector('[data-partnership-id="' + partnershipId + '"]'); + if (card) { + card.style.transition = 'all 0.3s ease'; + card.style.opacity = '0'; + card.style.transform = 'translateY(-20px)'; + setTimeout(function() { + card.remove(); + if (document.querySelectorAll('[data-partnership-id]').length === 0) { + window.location.reload(); + } + }, 300); + } + } else { + showNotification('Error: ' + data.error, 'error'); + } + }) + .catch(function(error) { + console.error('Error:', error); + showNotification('Failed to end partnership', 'error'); + }); +} + +// Partner requests functionality +function respondToRequest(requestId, action) { + var card = document.querySelector('[data-request-id="' + requestId + '"]'); + var buttons = card.querySelectorAll('.request-actions button'); + + // Disable buttons during request + for (var i = 0; i < buttons.length; i++) { + buttons[i].disabled = true; + buttons[i].style.opacity = '0.6'; + } + + fetch('/api/respond-to-request/' + requestId + '/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ action: action }) + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + showNotification('Request ' + action + 'ed successfully!', 'success'); + + // Update the card UI + var statusElement = card.querySelector('.request-status'); + var actionsElement = card.querySelector('.request-actions'); + + statusElement.className = 'request-status status-' + action + 'ed'; + statusElement.textContent = action === 'accept' ? 'ACCEPTED' : 'DECLINED'; + + // Replace actions with status message + var statusHTML; + if (action === 'accept') { + statusHTML = '
' + + '' + + 'You accepted this request!' + + '
' + + 'View your partnerships →' + + '
' + + '
'; + } else { + statusHTML = '
' + + '' + + 'You declined this request' + + '
'; + } + actionsElement.innerHTML = statusHTML; + + } else { + showNotification('Error: ' + data.error, 'error'); + // Re-enable buttons on error + for (var i = 0; i < buttons.length; i++) { + buttons[i].disabled = false; + buttons[i].style.opacity = '1'; + } + } + }) + .catch(function(error) { + console.error('Error:', error); + showNotification('Failed to respond to request', 'error'); + // Re-enable buttons on error + for (var i = 0; i < buttons.length; i++) { + buttons[i].disabled = false; + buttons[i].style.opacity = '1'; + } + }); +} + +// Utility function to get CSRF token +function getCookie(name) { + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} \ No newline at end of file diff --git a/core/templates/dashboard.html b/core/templates/dashboard.html index c80f819..f2d1cd5 100644 --- a/core/templates/dashboard.html +++ b/core/templates/dashboard.html @@ -79,8 +79,28 @@

Reminders & Timetable

👥

Find Study Partners

-

Connect with peers who share your learning goals and interests. Find

- +

Connect with peers who share your learning goals and interests.

+ + + +
+ +
+
🤝
+

My Study Partners

+

Manage your study partnerships and schedule collaborative sessions.

+ + + +
+ +
+
📧
+

Partner Requests

+

View and respond to study partner requests from other students.

+ + +
diff --git a/core/templates/my_partners.html b/core/templates/my_partners.html new file mode 100644 index 0000000..c7cfe5d --- /dev/null +++ b/core/templates/my_partners.html @@ -0,0 +1,651 @@ +{% load static %} +{% load partnership_extras %} + + + + + + My Study Partners - PeerPrep + + + + + + +
+
+
+
+ + + + + +
+
+ + +
+ +
+
+

+ + Active Partnerships +

+ {% if partnerships %} + + {{ partnerships|length }} partner{{ partnerships|length|pluralize }} + + {% endif %} +
+ + {% if partnerships %} + {% for partnership in partnerships %} + {% with partner=partnership|get_partner:user %} +
+
+
+
+ {% if partner.study_profile %} + {{ partner.study_profile.get_user_initials }} + {% else %} + {{ partner.username.0|upper }} + {% endif %} +
+
+

+ {% if partner.study_profile %} + {{ partner.study_profile.get_display_name }} + {% else %} + {{ partner.username }} + {% endif %} +

+
+ Partners since {{ partnership.created_at|date:"M Y" }} + {% if partner.study_profile and partner.study_profile.study_level %} + • {{ partner.study_profile.get_study_level_display }} + {% endif %} + {% if partner.study_profile and partner.study_profile.timezone %} + • {{ partner.study_profile.timezone }} + {% endif %} +
+
+
+
+ +
+
+ {{ partnership.total_sessions }} + Sessions +
+
+ + {% if partnership.last_session %} + {{ partnership.last_session|timesince }} ago + {% else %} + Never + {% endif %} + + Last Session +
+
+ + {% if partner.study_profile and partner.study_profile.get_subjects_list %} +
+
Common subjects:
+
+ {% for subject in partner.study_profile.get_subjects_list|slice:":4" %} + + {{ subject }} + + {% endfor %} +
+
+ {% endif %} + +
+ + + +
+
+ {% endwith %} + {% endfor %} + {% else %} +
+ +

No study partners yet

+

When you connect with other students, they will appear here.

+ +
+ {% endif %} +
+ + +
+
+

+ + Sessions +

+
+ + +
+

+ + Upcoming +

+ + {% if upcoming_sessions %} + {% for session in upcoming_sessions %} +
+
+
{{ session.title }}
+ Upcoming +
+
+
{{ session.subject }}
+
{{ session.scheduled_time|date:"M d, Y \a\t g:i A" }}
+
{{ session.duration_hours }} hours
+ {% if session.description %} +
{{ session.description|truncatechars:100 }}
+ {% endif %} +
+
+ {% endfor %} + {% else %} +
+ + No upcoming sessions +
+ {% endif %} +
+ + +
+

+ + Recent +

+ + {% if recent_sessions %} + {% for session in recent_sessions %} +
+
+
{{ session.title }}
+ + {% if session.is_completed %}Completed{% else %}Scheduled{% endif %} + +
+
+
{{ session.subject }}
+
{{ session.scheduled_time|date:"M d, Y" }}
+ {% if session.is_completed and session.notes %} +
+ + {{ session.notes|truncatechars:80 }} +
+ {% endif %} +
+
+ {% endfor %} + {% else %} +
+ + No recent sessions +
+ {% endif %} +
+
+
+
+
+ + + + + + {% include "footer.html" %} + + + \ No newline at end of file diff --git a/core/templates/partner_requests.html b/core/templates/partner_requests.html new file mode 100644 index 0000000..22582cb --- /dev/null +++ b/core/templates/partner_requests.html @@ -0,0 +1,499 @@ +{% load static %} + + + + + + Partner Requests - PeerPrep + + + + + + +
+
+
+
+ + + + + +
+
+ + +
+ +
+
+

+ + Incoming Requests +

+
+ + {% if incoming_requests %} + {% for request in incoming_requests %} +
+
+
+
+ {% if request.from_user.study_profile %} + {{ request.from_user.study_profile.get_user_initials }} + {% else %} + {{ request.from_user.username.0|upper }} + {% endif %} +
+ +
+
+ {{ request.get_status_display }} +
+
+ +
+ "{{ request.message }}" +
+ + {% if request.status == 'pending' %} +
+ + +
+ {% elif request.status == 'accepted' %} +
+ + You accepted this request! + +
+ {% else %} +
+ + You declined this request +
+ {% endif %} +
+ {% endfor %} + {% else %} +
+ +

No incoming requests

+

When other students want to study with you, their requests will appear here.

+
+ {% endif %} +
+ + +
+
+

+ + Sent Requests +

+
+ + {% if outgoing_requests %} + {% for request in outgoing_requests %} +
+
+
+
+ {% if request.to_user.study_profile %} + {{ request.to_user.study_profile.get_user_initials }} + {% else %} + {{ request.to_user.username.0|upper }} + {% endif %} +
+ +
+
+ {{ request.get_status_display }} +
+
+ +
+ "{{ request.message }}" +
+ + {% if request.status == 'pending' %} +
+ + Waiting for response... +
+ {% elif request.status == 'accepted' %} +
+ + Request accepted! + {% if request.responded_at %} +
+ Accepted on {{ request.responded_at|date:"M d, Y" }} +
+ {% endif %} +
+ {% else %} +
+ + Request declined + {% if request.responded_at %} +
+ Declined on {{ request.responded_at|date:"M d, Y" }} +
+ {% endif %} +
+ {% endif %} +
+ {% endfor %} + {% else %} +
+ +

No sent requests

+

Requests you send to other students will appear here.

+ +
+ {% endif %} +
+
+
+
+ + + + + + {% include "footer.html" %} + + + \ No newline at end of file diff --git a/core/templates/study_partners.html b/core/templates/study_partners.html new file mode 100644 index 0000000..2f389fa --- /dev/null +++ b/core/templates/study_partners.html @@ -0,0 +1,907 @@ +{% load static %} + + + + + + Find Study Partners - PeerPrep + + + + + + +
+
+
+
+ + + + + +
+
+ + + {% if not user.is_authenticated %} +
+ + Please log in to find and connect with study partners. +
+ Login + Register +
+
+ {% elif not user_profile or not user_profile.subjects %} +
+ + Complete your study profile to get better partner matches! +
+ Setup Profile +
+
+ {% endif %} + + {% if user.is_authenticated %} + +
+

+ + Search Filters +

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+

+ + Study Partners +

+ +
+ +
+ +
Searching for study partners...
+
+ +
+ + + + +
+ {% endif %} +
+
+ + + + + + + {% include "footer.html" %} + + + \ No newline at end of file diff --git a/core/templates/study_profile.html b/core/templates/study_profile.html new file mode 100644 index 0000000..1c5d679 --- /dev/null +++ b/core/templates/study_profile.html @@ -0,0 +1,323 @@ +{% load static %} + + + + + + Study Profile - PeerPrep + + + + + +
+
+
+
+ + + + + +
+
+ + + +
+
+ {% csrf_token %} + + +
+

+ + Basic Information +

+ +
+ + {{ form.bio }} + + Tell others about your study goals, interests, and what you hope to achieve. + +
+ +
+
+ + {{ form.study_level }} +
+ +
+ + + Turn this off if you're not currently looking for new study partners. + +
+
+
+ + +
+

+ + Study Preferences +

+ +
+ + {{ form.subjects }} + + Separate multiple subjects with commas (e.g., Mathematics, Physics, Computer Science) + +
+ +
+ + {{ form.study_goals }} + + What are you trying to achieve? Exams, projects, skill development, etc. + +
+ +
+ + {{ form.preferred_study_times }} + + When do you prefer to study? (e.g., morning, evening, weekends) + +
+
+ + +
+

+ + Location & Contact +

+ +
+
+ + {{ form.timezone }} +
+ +
+ + {{ form.languages }} + + Languages you can communicate in + +
+
+ +
+
+ + {{ form.contact_preference }} +
+ +
+ + {{ form.contact_info }} + + Only if you selected a contact method above + +
+
+
+ +
+ + + + Find Partners + +
+
+ + {% if profile.subjects %} + +
+

+ + Profile Preview +

+
+
Your Profile
+ +
+
{{ profile.get_user_initials }}
+
+

{{ profile.get_display_name }}

+
{{ profile.get_study_level_display }}
+
+
+ + {% if profile.bio %} +
{{ profile.bio }}
+ {% endif %} + + {% if profile.get_subjects_list %} +
+ {% for subject in profile.get_subjects_list|slice:":3" %} + {{ subject }} + {% endfor %} + {% if profile.get_subjects_list|length > 3 %} + +{{ profile.get_subjects_list|length|add:"-3" }} more + {% endif %} +
+ {% endif %} + +
+
+ + {{ profile.timezone }} +
+
+ + {{ profile.get_languages_list|slice:":2"|join:", " }} +
+ {% if profile.get_study_times_list %} +
+ + {{ profile.get_study_times_list|slice:":2"|join:", " }} +
+ {% endif %} +
+ + {% if profile.study_goals %} +
+
+ + {{ profile.study_goals|truncatechars:100 }} +
+
+ {% endif %} + +
+ + This is how your profile appears to other students +
+
+
+ {% endif %} +
+
+
+ + + + {% include "footer.html" %} + + + \ No newline at end of file diff --git a/core/templatetags/partnership_extras.py b/core/templatetags/partnership_extras.py new file mode 100644 index 0000000..2ca2b9d --- /dev/null +++ b/core/templatetags/partnership_extras.py @@ -0,0 +1,20 @@ +# Create these files for custom template filters + +# 1. Create core/templatetags/__init__.py (empty file) + +# 2. Create core/templatetags/partnership_extras.py +from django import template + +register = template.Library() + +@register.filter +def get_partner(partnership, current_user): + """Get the other user in the partnership""" + return partnership.user2 if partnership.user1 == current_user else partnership.user1 + +@register.filter +def get_display_name_safe(user): + """Safely get display name for JavaScript""" + if hasattr(user, 'study_profile') and user.study_profile: + return user.study_profile.get_display_name() + return user.username \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 8e55cc3..4ce973c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -47,4 +47,18 @@ path('api/create-milestone//', views.create_milestone, name='api_create_milestone'), path('api/toggle-milestone//', views.toggle_milestone, name='api_toggle_milestone'), path('api/recent-activity/', views.get_recent_activity, name='api_recent_activity'), + # Add these URLs to your core/urls.py file (in the urlpatterns list) + + # Study Partners URLs + path('find-study-partners/', views.find_study_partners, name='find_study_partners'), + path('study-profile/', views.study_profile, name='study_profile'), + path('api/search-study-partners/', views.search_study_partners, name='api_search_study_partners'), + path('api/send-partner-request/', views.send_partner_request, name='api_send_partner_request'), + path('my-partner-requests/', views.my_partner_requests, name='my_partner_requests'), + path('api/respond-to-request//', views.respond_to_request, name='api_respond_to_request'), + path('my-study-partners/', views.my_study_partners, name='my_study_partners'), + path('api/schedule-session/', views.schedule_session, name='api_schedule_session'), + path('api/partnership-sessions//', views.get_partnership_sessions, name='api_partnership_sessions'), + path('api/complete-session//', views.complete_session, name='api_complete_session'), + path('api/end-partnership//', views.end_partnership, name='api_end_partnership'), ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index f17f485..3f334b1 100644 --- a/core/views.py +++ b/core/views.py @@ -18,6 +18,9 @@ import json from .models import Goal, Milestone, StudySession, Achievement, UserStats, WeeklyGoal from .forms import GoalForm, MilestoneForm, StudySessionForm, WeeklyGoalForm, GoalUpdateForm +from django.core.paginator import Paginator +from .models import StudyProfile, StudyPartnerRequest, StudyPartnership, StudySession +from .forms import StudyProfileForm, StudyPartnerRequestForm, StudySessionForm, StudyPartnerSearchForm,PartnerStudySession def user_login(request): if request.method == 'POST': @@ -1117,5 +1120,507 @@ def get_recent_activity(request): 'activities': activities[:10] # Return top 10 most recent }) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +# Add these views to your core/views.py file + +from django.db.models import Q, Count +from django.core.paginator import Paginator +from django.contrib.auth.models import User +from .models import StudyProfile, StudyPartnerRequest, StudyPartnership, StudySession +from .forms import StudyProfileForm, StudyPartnerRequestForm, StudySessionForm, StudyPartnerSearchForm,PartnerStudySession +import json +from django.utils import timezone + +# Study Partners Views + +def find_study_partners(request): + """Main page for finding study partners""" + # Get or create user's study profile + user_profile = None + if request.user.is_authenticated: + user_profile, created = StudyProfile.objects.get_or_create( + user=request.user, + defaults={ + 'subjects': '', + 'study_level': 'intermediate', + 'preferred_study_times': '', + 'timezone': 'UTC+0', + 'languages': 'English', + 'is_available': True + } + ) + + context = { + 'user_profile': user_profile, + 'user_is_authenticated': request.user.is_authenticated, + } + return render(request, 'study_partners.html', context) + +@login_required +def study_profile(request): + """View and edit user's study profile""" + profile, created = StudyProfile.objects.get_or_create( + user=request.user, + defaults={ + 'subjects': '', + 'study_level': 'intermediate', + 'preferred_study_times': '', + 'timezone': 'UTC+0', + 'languages': 'English', + 'is_available': True + } + ) + + if request.method == 'POST': + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + form = StudyProfileForm(request.POST, instance=profile) + if form.is_valid(): + form.save() + return JsonResponse({ + 'success': True, + 'message': 'Profile updated successfully!' + }) + return JsonResponse({ + 'success': False, + 'errors': form.errors + }) + else: + form = StudyProfileForm(request.POST, instance=profile) + if form.is_valid(): + form.save() + messages.success(request, 'Your study profile has been updated successfully!') + return redirect('study_profile') + else: + form = StudyProfileForm(instance=profile) + + context = { + 'form': form, + 'profile': profile, + } + return render(request, 'study_profile.html', context) + +def search_study_partners(request): + """API endpoint to search for study partners""" + if not request.user.is_authenticated: + return JsonResponse({'partners': [], 'success': False, 'error': 'Authentication required'}) + + # Get search parameters + subject = request.GET.get('subject', '') + study_level = request.GET.get('study_level', '') + timezone = request.GET.get('timezone', '') + search_query = request.GET.get('search_query', '') + page = int(request.GET.get('page', 1)) + + # Base query: exclude current user and only show available profiles + profiles = StudyProfile.objects.filter( + is_available=True + ).exclude(user=request.user).select_related('user') + + # Apply filters + if subject and subject != '': + profiles = profiles.filter(subjects__icontains=subject) + + if study_level and study_level != '': + profiles = profiles.filter(study_level=study_level) + + if timezone and timezone != '': + profiles = profiles.filter(timezone=timezone) + + if search_query: + profiles = profiles.filter( + Q(user__first_name__icontains=search_query) | + Q(user__last_name__icontains=search_query) | + Q(user__username__icontains=search_query) | + Q(bio__icontains=search_query) | + Q(subjects__icontains=search_query) | + Q(study_goals__icontains=search_query) + ) + + # Pagination + paginator = Paginator(profiles, 12) # 12 profiles per page + profiles_page = paginator.get_page(page) + + # Get current user's sent requests to avoid duplicate requests + sent_requests = StudyPartnerRequest.objects.filter( + from_user=request.user, + status='pending' + ).values_list('to_user_id', flat=True) + + # Get current user's partnerships + partnerships = StudyPartnership.objects.filter( + Q(user1=request.user) | Q(user2=request.user), + is_active=True + ) + partnered_users = set() + for partnership in partnerships: + partnered_users.add(partnership.user1.id if partnership.user2 == request.user else partnership.user2.id) + + partners_data = [] + for profile in profiles_page: + # Calculate compatibility score (simple algorithm) + compatibility_score = calculate_compatibility_score( + request.user.study_profile if hasattr(request.user, 'study_profile') else None, + profile + ) + + partners_data.append({ + 'id': profile.user.id, + 'profile_id': profile.id, + 'name': profile.get_display_name(), + 'username': profile.user.username, + 'initials': profile.get_user_initials(), + 'bio': profile.bio, + 'subjects': profile.get_subjects_list(), + 'study_level': profile.study_level, + 'study_goals': profile.study_goals, + 'preferred_study_times': profile.get_study_times_list(), + 'timezone': profile.timezone, + 'languages': profile.get_languages_list(), + 'contact_preference': profile.contact_preference, + 'created_at': profile.created_at.isoformat(), + 'compatibility_score': compatibility_score, + 'request_sent': profile.user.id in sent_requests, + 'is_partner': profile.user.id in partnered_users, + }) + + return JsonResponse({ + 'partners': partners_data, + 'has_next': profiles_page.has_next(), + 'has_previous': profiles_page.has_previous(), + 'current_page': profiles_page.number, + 'total_pages': paginator.num_pages, + 'total_count': paginator.count, + 'success': True + }) + +def calculate_compatibility_score(user_profile, other_profile): + """Calculate compatibility score between two study profiles""" + if not user_profile: + return 50 # Default score if user has no profile + + score = 0 + max_score = 100 + + # Subject compatibility (40% weight) + user_subjects = set([s.lower().strip() for s in user_profile.get_subjects_list()]) + other_subjects = set([s.lower().strip() for s in other_profile.get_subjects_list()]) + + if user_subjects and other_subjects: + common_subjects = user_subjects.intersection(other_subjects) + if common_subjects: + subject_score = (len(common_subjects) / max(len(user_subjects), len(other_subjects))) * 40 + score += subject_score + + # Study level compatibility (20% weight) + level_mapping = {'beginner': 1, 'intermediate': 2, 'advanced': 3, 'expert': 4} + user_level = level_mapping.get(user_profile.study_level, 2) + other_level = level_mapping.get(other_profile.study_level, 2) + level_diff = abs(user_level - other_level) + + if level_diff == 0: + score += 20 + elif level_diff == 1: + score += 15 + elif level_diff == 2: + score += 5 + + # Timezone compatibility (20% weight) + user_tz = user_profile.timezone + other_tz = other_profile.timezone + + if user_tz == other_tz: + score += 20 + else: + # Calculate timezone difference + try: + user_offset = float(user_tz.replace('UTC', '').replace('+', '')) + other_offset = float(other_tz.replace('UTC', '').replace('+', '')) + tz_diff = abs(user_offset - other_offset) + + if tz_diff <= 2: + score += 15 + elif tz_diff <= 4: + score += 10 + elif tz_diff <= 8: + score += 5 + except: + pass + + # Language compatibility (10% weight) + user_languages = set([l.lower().strip() for l in user_profile.get_languages_list()]) + other_languages = set([l.lower().strip() for l in other_profile.get_languages_list()]) + + common_languages = user_languages.intersection(other_languages) + if common_languages: + score += 10 + + # Study time compatibility (10% weight) + user_times = set([t.lower().strip() for t in user_profile.get_study_times_list()]) + other_times = set([t.lower().strip() for t in other_profile.get_study_times_list()]) + + if user_times and other_times: + common_times = user_times.intersection(other_times) + if common_times: + score += 10 + + return min(int(score), 100) + +@login_required +@require_POST +def send_partner_request(request): + """Send a study partner request""" + try: + data = json.loads(request.body) + to_user_id = data.get('user_id') + message = data.get('message', '') + + if not to_user_id: + return JsonResponse({'success': False, 'error': 'User ID is required'}) + + to_user = get_object_or_404(User, id=to_user_id) + + # Check if request already exists + existing_request = StudyPartnerRequest.objects.filter( + from_user=request.user, + to_user=to_user + ).first() + + if existing_request: + return JsonResponse({'success': False, 'error': 'Request already sent to this user'}) + + # Check if they're already partners + existing_partnership = StudyPartnership.objects.filter( + Q(user1=request.user, user2=to_user) | Q(user1=to_user, user2=request.user), + is_active=True + ).first() + + if existing_partnership: + return JsonResponse({'success': False, 'error': 'You are already study partners with this user'}) + + # Create the request + partner_request = StudyPartnerRequest.objects.create( + from_user=request.user, + to_user=to_user, + message=message + ) + + return JsonResponse({ + 'success': True, + 'message': 'Study partner request sent successfully!' + }) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +@login_required +def my_partner_requests(request): + """View incoming and outgoing partner requests""" + incoming_requests = StudyPartnerRequest.objects.filter( + to_user=request.user + ).select_related('from_user', 'from_user__study_profile').order_by('-created_at') + + outgoing_requests = StudyPartnerRequest.objects.filter( + from_user=request.user + ).select_related('to_user', 'to_user__study_profile').order_by('-created_at') + + context = { + 'incoming_requests': incoming_requests, + 'outgoing_requests': outgoing_requests, + } + return render(request, 'partner_requests.html', context) + +@login_required +@require_POST +def respond_to_request(request, request_id): + """Respond to a partner request (accept/decline)""" + try: + partner_request = get_object_or_404(StudyPartnerRequest, id=request_id, to_user=request.user) + + if partner_request.status != 'pending': + return JsonResponse({'success': False, 'error': 'Request has already been responded to'}) + + data = json.loads(request.body) + action = data.get('action') # 'accept' or 'decline' + + if action not in ['accept', 'decline']: + return JsonResponse({'success': False, 'error': 'Invalid action'}) + + partner_request.status = 'accepted' if action == 'accept' else 'declined' + partner_request.responded_at = timezone.now() + partner_request.save() + + # Create partnership if accepted + if action == 'accept': + StudyPartnership.objects.get_or_create( + user1=partner_request.from_user, + user2=partner_request.to_user, + defaults={'is_active': True} + ) + + return JsonResponse({ + 'success': True, + 'message': f'Request {action}ed successfully!' + }) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +@login_required +def my_study_partners(request): + """View current study partnerships and sessions""" + partnerships = StudyPartnership.objects.filter( + Q(user1=request.user) | Q(user2=request.user), + is_active=True + ).select_related('user1', 'user2', 'user1__study_profile', 'user2__study_profile') + + # Get upcoming sessions + upcoming_sessions = PartnerStudySession.objects.filter( + partnership__in=partnerships, + scheduled_time__gte=timezone.now(), + is_completed=False + ).select_related('partnership', 'created_by').order_by('scheduled_time')[:5] + + # Get recent sessions + recent_sessions = PartnerStudySession.objects.filter( + partnership__in=partnerships + ).select_related('partnership', 'created_by').order_by('-created_at')[:5] + + context = { + 'partnerships': partnerships, + 'upcoming_sessions': upcoming_sessions, + 'recent_sessions': recent_sessions, + } + return render(request, 'my_partners.html', context) + +@login_required +@require_POST +def schedule_session(request): + """Schedule a study session with a partner""" + try: + data = json.loads(request.body) + partnership_id = data.get('partnership_id') + + partnership = get_object_or_404(StudyPartnership, id=partnership_id) + + # Verify user is part of this partnership + if request.user not in [partnership.user1, partnership.user2]: + return JsonResponse({'success': False, 'error': 'You are not part of this partnership'}) + + # Create session data + session_data = { + 'title': data.get('title'), + 'description': data.get('description', ''), + 'subject': data.get('subject'), + 'scheduled_time': data.get('scheduled_time'), + 'duration_hours': data.get('duration_hours', 2.0) + } + + form = StudySessionForm(session_data) + if form.is_valid(): + session = form.save(commit=False) + session.partnership = partnership + session.created_by = request.user + session.save() + + # Update partnership stats + partnership.total_sessions += 1 + partnership.save() + + return JsonResponse({ + 'success': True, + 'message': 'Study session scheduled successfully!', + 'session_id': session.id + }) + + return JsonResponse({ + 'success': False, + 'errors': form.errors + }) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +@login_required +def get_partnership_sessions(request, partnership_id): + """Get sessions for a specific partnership""" + partnership = get_object_or_404(StudyPartnership, id=partnership_id) + + # Verify user is part of this partnership + if request.user not in [partnership.user1, partnership.user2]: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + sessions = PartnerStudySession.objects.filter( + partnership=partnership + ).select_related('created_by').order_by('-scheduled_time') + + sessions_data = [] + for session in sessions: + sessions_data.append({ + 'id': session.id, + 'title': session.title, + 'description': session.description, + 'subject': session.subject, + 'scheduled_time': session.scheduled_time.isoformat(), + 'duration_hours': float(session.duration_hours), + 'created_by': session.created_by.username, + 'is_completed': session.is_completed, + 'notes': session.notes, + 'created_at': session.created_at.isoformat(), + }) + + return JsonResponse({ + 'sessions': sessions_data, + 'success': True + }) + +@login_required +@require_POST +def complete_session(request, session_id): + """Mark a study session as completed""" + try: + session = get_object_or_404(PartnerStudySession, id=session_id) + + # Verify user is part of this session's partnership + if request.user not in [session.partnership.user1, session.partnership.user2]: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + data = json.loads(request.body) + session.is_completed = True + session.notes = data.get('notes', '') + session.save() + + # Update partnership last session + session.partnership.last_session = timezone.now() + session.partnership.save() + + return JsonResponse({ + 'success': True, + 'message': 'Session marked as completed!' + }) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + +@login_required +@require_POST +def end_partnership(request, partnership_id): + """End a study partnership""" + try: + partnership = get_object_or_404(StudyPartnership, id=partnership_id) + + # Verify user is part of this partnership + if request.user not in [partnership.user1, partnership.user2]: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + partnership.is_active = False + partnership.save() + + return JsonResponse({ + 'success': True, + 'message': 'Partnership ended successfully.' + }) + except Exception as e: return JsonResponse({'success': False, 'error': str(e)}) \ No newline at end of file diff --git a/peerprep/urls.py b/peerprep/urls.py index 3f0fa3f..096b456 100644 --- a/peerprep/urls.py +++ b/peerprep/urls.py @@ -59,7 +59,17 @@ path('api/create-milestone//', views.create_milestone, name='api_create_milestone'), path('api/toggle-milestone//', views.toggle_milestone, name='api_toggle_milestone'), path('api/recent-activity/', views.get_recent_activity, name='api_recent_activity'), - + path('find-study-partners/', views.find_study_partners, name='find_study_partners'), + path('study-profile/', views.study_profile, name='study_profile'), + path('api/search-study-partners/', views.search_study_partners, name='api_search_study_partners'), + path('api/send-partner-request/', views.send_partner_request, name='api_send_partner_request'), + path('my-partner-requests/', views.my_partner_requests, name='my_partner_requests'), + path('api/respond-to-request//', views.respond_to_request, name='api_respond_to_request'), + path('my-study-partners/', views.my_study_partners, name='my_study_partners'), + path('api/schedule-session/', views.schedule_session, name='api_schedule_session'), + path('api/partnership-sessions//', views.get_partnership_sessions, name='api_partnership_sessions'), + path('api/complete-session//', views.complete_session, name='api_complete_session'), + path('api/end-partnership//', views.end_partnership, name='api_end_partnership'), ] if settings.DEBUG: