-
-
Notifications
You must be signed in to change notification settings - Fork 177
feat: add mentorship system with mentor profiles, requests, sessions, and ratings #1042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| # Generated by Django 5.1.15 on 2026-03-22 15:55 | ||
|
|
||
| import django.core.validators | ||
| import django.db.models.deletion | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"), | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="MentorProfile", | ||
| fields=[ | ||
| ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), | ||
| ("bio", models.TextField(blank=True)), | ||
| ("experience_years", models.PositiveIntegerField(default=0)), | ||
| ("hourly_rate", models.DecimalField(decimal_places=2, default=0.0, max_digits=8)), | ||
| ("is_free", models.BooleanField(default=True)), | ||
| ( | ||
| "availability", | ||
| models.CharField( | ||
| choices=[ | ||
| ("weekdays", "Weekdays"), | ||
| ("weekends", "Weekends"), | ||
| ("evenings", "Evenings"), | ||
| ("flexible", "Flexible"), | ||
| ], | ||
| default="flexible", | ||
| max_length=20, | ||
| ), | ||
| ), | ||
| ("is_active", models.BooleanField(default=True)), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("updated_at", models.DateTimeField(auto_now=True)), | ||
| ("subjects", models.ManyToManyField(blank=True, related_name="mentors", to="web.subject")), | ||
| ( | ||
| "user", | ||
| models.OneToOneField( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="mentor_profile", | ||
| to=settings.AUTH_USER_MODEL, | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "ordering": ["-created_at"], | ||
| }, | ||
| ), | ||
| migrations.CreateModel( | ||
| name="MentorshipSession", | ||
| fields=[ | ||
| ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), | ||
| ("scheduled_at", models.DateTimeField()), | ||
| ("duration_minutes", models.PositiveIntegerField(default=60)), | ||
| ( | ||
| "status", | ||
| models.CharField( | ||
| choices=[("scheduled", "Scheduled"), ("completed", "Completed"), ("cancelled", "Cancelled")], | ||
| default="scheduled", | ||
| max_length=10, | ||
| ), | ||
| ), | ||
| ("notes", models.TextField(blank=True)), | ||
| ( | ||
| "rating", | ||
| models.PositiveIntegerField( | ||
| blank=True, | ||
| null=True, | ||
| validators=[ | ||
| django.core.validators.MinValueValidator(1), | ||
| django.core.validators.MaxValueValidator(5), | ||
| ], | ||
| ), | ||
| ), | ||
| ("review", models.TextField(blank=True)), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("updated_at", models.DateTimeField(auto_now=True)), | ||
| ( | ||
| "mentor", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, related_name="sessions", to="web.mentorprofile" | ||
| ), | ||
| ), | ||
| ( | ||
| "student", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="mentorship_sessions", | ||
| to=settings.AUTH_USER_MODEL, | ||
| ), | ||
| ), | ||
| ( | ||
| "subject", | ||
| models.ForeignKey( | ||
| blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="web.subject" | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "ordering": ["-scheduled_at"], | ||
| }, | ||
| ), | ||
| migrations.CreateModel( | ||
| name="MentorshipRequest", | ||
| fields=[ | ||
| ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), | ||
| ("message", models.TextField()), | ||
| ( | ||
| "status", | ||
| models.CharField( | ||
| choices=[ | ||
| ("pending", "Pending"), | ||
| ("accepted", "Accepted"), | ||
| ("declined", "Declined"), | ||
| ("cancelled", "Cancelled"), | ||
| ], | ||
| default="pending", | ||
| max_length=10, | ||
| ), | ||
| ), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("updated_at", models.DateTimeField(auto_now=True)), | ||
| ( | ||
| "mentor", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, related_name="requests", to="web.mentorprofile" | ||
| ), | ||
| ), | ||
| ( | ||
| "student", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="mentorship_requests", | ||
| to=settings.AUTH_USER_MODEL, | ||
| ), | ||
| ), | ||
| ( | ||
| "subject", | ||
| models.ForeignKey( | ||
| blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="web.subject" | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "ordering": ["-created_at"], | ||
|
|
||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3176,3 +3176,95 @@ class Meta: | |
| ordering = ["-last_updated"] | ||
| verbose_name = "Virtual Classroom Whiteboard" | ||
| verbose_name_plural = "Virtual Classroom Whiteboards" | ||
|
|
||
|
|
||
| class MentorProfile(models.Model): | ||
| """A user who offers mentorship in one or more subjects.""" | ||
|
|
||
| AVAILABILITY_CHOICES = [ | ||
| ("weekdays", "Weekdays"), | ||
| ("weekends", "Weekends"), | ||
| ("evenings", "Evenings"), | ||
| ("flexible", "Flexible"), | ||
| ] | ||
|
|
||
| user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="mentor_profile") | ||
| subjects = models.ManyToManyField(Subject, related_name="mentors", blank=True) | ||
| bio = models.TextField(blank=True) | ||
| experience_years = models.PositiveIntegerField(default=0) | ||
| hourly_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0.00) | ||
| is_free = models.BooleanField(default=True) | ||
| availability = models.CharField(max_length=20, choices=AVAILABILITY_CHOICES, default="flexible") | ||
|
Comment on lines
+3195
to
+3197
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Right now non-form writes can store a negative rate, or persist a paid mentor at 💡 One way to enforce the pricing invariant- hourly_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0.00)
+ hourly_rate = models.DecimalField(
+ max_digits=8,
+ decimal_places=2,
+ default=0,
+ validators=[MinValueValidator(0)],
+ )
@@
class Meta:
ordering = ["-created_at"]
+ constraints = [
+ models.CheckConstraint(
+ check=(
+ models.Q(is_free=True, hourly_rate=0)
+ | models.Q(is_free=False, hourly_rate__gt=0)
+ ),
+ name="mentor_profile_rate_matches_free_flag",
+ )
+ ]Also applies to: 3202-3203 🤖 Prompt for AI Agents |
||
| is_active = models.BooleanField(default=True) | ||
| created_at = models.DateTimeField(auto_now_add=True) | ||
| updated_at = models.DateTimeField(auto_now=True) | ||
|
|
||
| class Meta: | ||
| ordering = ["-created_at"] | ||
|
|
||
| def __str__(self) -> str: | ||
| return f"{self.user.username} - Mentor" | ||
|
|
||
| @property | ||
| def average_rating(self): | ||
| result = self.sessions.filter(rating__isnull=False).aggregate(avg=Avg("rating"))["avg"] | ||
| return round(result, 1) if result else None | ||
|
|
||
| @property | ||
| def total_sessions(self) -> int: | ||
| return self.sessions.filter(status="completed").count() | ||
|
ayesha1145 marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class MentorshipRequest(models.Model): | ||
| """A student request for mentorship.""" | ||
|
|
||
| STATUS_CHOICES = [ | ||
| ("pending", "Pending"), | ||
| ("accepted", "Accepted"), | ||
| ("declined", "Declined"), | ||
| ("cancelled", "Cancelled"), | ||
| ] | ||
|
|
||
| mentor = models.ForeignKey(MentorProfile, on_delete=models.CASCADE, related_name="requests") | ||
| student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="mentorship_requests") | ||
| subject = models.ForeignKey(Subject, on_delete=models.SET_NULL, null=True, blank=True) | ||
| message = models.TextField() | ||
| status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="pending") | ||
| created_at = models.DateTimeField(auto_now_add=True) | ||
| updated_at = models.DateTimeField(auto_now=True) | ||
|
|
||
| class Meta: | ||
| ordering = ["-created_at"] | ||
|
|
||
| def __str__(self) -> str: | ||
| return f"{self.student.username} -> {self.mentor.user.username} ({self.status})" | ||
|
|
||
|
|
||
| class MentorshipSession(models.Model): | ||
| """A scheduled or completed 1-on-1 mentorship session.""" | ||
|
|
||
| STATUS_CHOICES = [ | ||
| ("scheduled", "Scheduled"), | ||
| ("completed", "Completed"), | ||
| ("cancelled", "Cancelled"), | ||
| ] | ||
|
|
||
| mentor = models.ForeignKey(MentorProfile, on_delete=models.CASCADE, related_name="sessions") | ||
| student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="mentorship_sessions") | ||
| subject = models.ForeignKey(Subject, on_delete=models.SET_NULL, null=True, blank=True) | ||
| scheduled_at = models.DateTimeField() | ||
| duration_minutes = models.PositiveIntegerField(default=60, validators=[MinValueValidator(15), MaxValueValidator(240)]) | ||
| status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="scheduled") | ||
| notes = models.TextField(blank=True) | ||
| rating = models.PositiveIntegerField( | ||
| null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(5)] | ||
| ) | ||
| review = models.TextField(blank=True) | ||
|
Comment on lines
+3257
to
+3262
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only completed sessions should be allowed to carry ratings/reviews. Nothing here stops admin or other direct writes from attaching 🧩 Add a data-integrity constraint class Meta:
ordering = ["-scheduled_at"]
+ constraints = [
+ models.CheckConstraint(
+ check=(
+ models.Q(status="completed")
+ | (models.Q(rating__isnull=True) & models.Q(review=""))
+ ),
+ name="mentorship_session_rating_requires_completed",
+ )
+ ]Also applies to: 3266-3267 |
||
| created_at = models.DateTimeField(auto_now_add=True) | ||
| updated_at = models.DateTimeField(auto_now=True) | ||
|
|
||
| class Meta: | ||
| ordering = ["-scheduled_at"] | ||
|
|
||
|
ayesha1145 marked this conversation as resolved.
|
||
| def __str__(self) -> str: | ||
| return f"{self.mentor.user.username} + {self.student.username} @ {self.scheduled_at:%Y-%m-%d %H:%M}" | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||||||||||||||||||||||||||||||
| {% extends "base.html" %} | ||||||||||||||||||||||||||||||||||||
| {% block title %}Become a Mentor{% endblock title %} | ||||||||||||||||||||||||||||||||||||
| {% block content %} | ||||||||||||||||||||||||||||||||||||
| <div class="container mx-auto px-4 py-8 max-w-2xl"> | ||||||||||||||||||||||||||||||||||||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6"> | ||||||||||||||||||||||||||||||||||||
| <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">{% if mentor %}Edit Mentor Profile{% else %}Become a Mentor{% endif %}</h1> | ||||||||||||||||||||||||||||||||||||
| <form method="post" class="space-y-5"> | ||||||||||||||||||||||||||||||||||||
| {% csrf_token %} | ||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||
| <label for="bio" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bio</label> | ||||||||||||||||||||||||||||||||||||
| <textarea id="bio" name="bio" rows="4" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">{{ mentor.bio|default:"" }}</textarea> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | ||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||
| <label for="experience_years" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Years of Experience</label> | ||||||||||||||||||||||||||||||||||||
| <input type="number" id="experience_years" name="experience_years" min="0" max="50" value="{{ mentor.experience_years|default:0 }}" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||
| <label for="availability" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Availability</label> | ||||||||||||||||||||||||||||||||||||
| <select id="availability" name="availability" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||||||||||||||||||||||||||||||||
| {% for val, label in availability_choices %}<option value="{{ val }}" {% if mentor.availability == val %}selected{% endif %}>{{ label }}</option>{% endfor %} | ||||||||||||||||||||||||||||||||||||
|
ayesha1145 marked this conversation as resolved.
Comment on lines
+19
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New mentor profiles default to the wrong availability. When 🎯 Preselect the actual default on create- {% for val, label in availability_choices %}<option value="{{ val }}" {% if mentor.availability == val %}selected{% endif %}>{{ label }}</option>{% endfor %}
+ {% for val, label in availability_choices %}
+ <option
+ value="{{ val }}"
+ {% if mentor and mentor.availability == val %}
+ selected
+ {% elif not mentor and val == "flexible" %}
+ selected
+ {% endif %}
+ >
+ {{ label }}
+ </option>
+ {% endfor %}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| </select> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| <div class="flex items-center space-x-4"> | ||||||||||||||||||||||||||||||||||||
| <div class="flex items-center"> | ||||||||||||||||||||||||||||||||||||
| <input type="checkbox" id="is_free" name="is_free" {% if not mentor or mentor.is_free %}checked{% endif %} class="w-4 h-4 text-teal-500 border-gray-300 rounded focus:ring-teal-300"> | ||||||||||||||||||||||||||||||||||||
| <label for="is_free" class="ml-2 text-sm text-gray-700 dark:text-gray-300">Free mentorship</label> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| <label for="hourly_rate" class="sr-only">Hourly rate</label><input type="number" id="hourly_rate" name="hourly_rate" min="0" step="0.01" value="{{ mentor.hourly_rate|default:0 }}" placeholder="Hourly rate (if paid)" aria-label="Hourly rate" class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subjects</label> | ||||||||||||||||||||||||||||||||||||
| <div class="grid grid-cols-2 md:grid-cols-3 gap-2"> | ||||||||||||||||||||||||||||||||||||
| {% for s in subjects %}<label class="flex items-center space-x-2"><input type="checkbox" name="subjects" value="{{ s.id }}" {% if mentor and s in mentor.subjects.all %}checked{% endif %} class="w-4 h-4 text-teal-500 border-gray-300 rounded focus:ring-teal-300"><span class="text-sm text-gray-700 dark:text-gray-300">{{ s.name }}</span></label>{% endfor %} | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
|
ayesha1145 marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| <div class="flex items-center space-x-3"> | ||||||||||||||||||||||||||||||||||||
| <button type="submit" class="bg-teal-300 hover:bg-teal-400 dark:bg-teal-600 dark:hover:bg-teal-500 text-white font-semibold px-6 py-2 rounded-lg transition duration-200 focus:outline-none focus:ring-2 focus:ring-teal-300">Save Profile</button> | ||||||||||||||||||||||||||||||||||||
| <a href="{% url 'mentor_list' %}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">Cancel</a> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| </form> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||
| {% endblock content %} | ||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.