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!' +
+ '
' +
+ '
';
+ } 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if partnerships %}
+ {% for partnership in partnerships %}
+ {% with partner=partnership|get_partner:user %}
+
+
+
+
+
+ {{ 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 %}
+
+
+
+
+
+
+
+
+
+
+ Upcoming
+
+
+ {% if upcoming_sessions %}
+ {% for session in upcoming_sessions %}
+
+
+
+
{{ 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.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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if incoming_requests %}
+ {% for request in incoming_requests %}
+
+
+
+
+ "{{ 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 %}
+
+
+
+
+
+
+ {% if outgoing_requests %}
+ {% for request in outgoing_requests %}
+
+
+
+
+ "{{ 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.
+
+
+ {% elif not user_profile or not user_profile.subjects %}
+
+
+
Complete your study profile to get better partner matches!
+
+
+ {% endif %}
+
+ {% if user.is_authenticated %}
+
+
+
+
+ Search Filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Searching for study partners...
+
+
+
+
+
+
+
No study partners found
+
Try adjusting your search filters or check back later for new members.
+
+
+
+
+ {% 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if profile.subjects %}
+
+
+
+
+ Profile Preview
+
+
+
Your Profile
+
+
+
+ {% 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: