Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions web/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
ForumTopic,
Goods,
LearningStreak,
MentorProfile,
MentorshipRequest,
MentorshipSession,
MembershipPlan,
MembershipSubscriptionEvent,
Notification,
Expand Down Expand Up @@ -610,6 +613,27 @@ class ChallengeSubmissionAdmin(admin.ModelAdmin):

# Unregister the default User admin and register our custom one
admin.site.unregister(User)
@admin.register(MentorProfile)
class MentorProfileAdmin(admin.ModelAdmin):
list_display = ("user", "is_active", "is_free", "hourly_rate", "availability", "created_at")
list_filter = ("is_active", "is_free", "availability")
search_fields = ("user__username", "user__email")


@admin.register(MentorshipRequest)
class MentorshipRequestAdmin(admin.ModelAdmin):
list_display = ("student", "mentor", "status", "created_at")
list_filter = ("status",)
search_fields = ("student__username", "mentor__user__username")


@admin.register(MentorshipSession)
class MentorshipSessionAdmin(admin.ModelAdmin):
list_display = ("mentor", "student", "scheduled_at", "status", "rating")
list_filter = ("status",)
search_fields = ("mentor__user__username", "student__username")
Comment thread
ayesha1145 marked this conversation as resolved.


admin.site.register(User, CustomUserAdmin)


Expand Down
155 changes: 155 additions & 0 deletions web/migrations/0064_add_mentorship_models.py
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"],

},
),
]
92 changes: 92 additions & 0 deletions web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate hourly_rate server-side and keep it consistent with is_free.

Right now non-form writes can store a negative rate, or persist a paid mentor at 0 / a free mentor with a stale non-zero rate. That breaks the free-vs-paid filter and the price shown in the mentorship UI.

💡 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
Verify each finding against the current code and only fix it if needed.

In `@web/models.py` around lines 3195 - 3197, Add server-side validation to
enforce hourly_rate >= 0 and the free/paid invariant: implement a model clean()
that raises ValidationError if hourly_rate is negative, if is_free is True but
hourly_rate != 0, or if is_free is False but hourly_rate == 0 (or set a minimum
>0 as business requires); then ensure save() calls full_clean() (or normalizes
values in save()) so non-form writes can't bypass it. Apply the same clean/save
changes to the other model instance that defines hourly_rate and is_free (the
duplicate at the other block around the same lines).

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()
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only completed sessions should be allowed to carry ratings/reviews.

Nothing here stops admin or other direct writes from attaching rating / review data to scheduled or cancelled sessions. Those rows would then feed MentorProfile.average_rating even though the session never finished.

🧩 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"]

Comment thread
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}"
45 changes: 45 additions & 0 deletions web/templates/mentorship/become_mentor.html
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 %}
Comment thread
ayesha1145 marked this conversation as resolved.
Comment on lines +19 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

New mentor profiles default to the wrong availability.

When mentor is absent, no option is marked selected, so the browser submits the first value (weekdays). The model default is "flexible", so a first-time submit stores different data than the model/API default.

🎯 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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 %}
<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 and mentor.availability == val %}
selected
{% elif not mentor and val == "flexible" %}
selected
{% endif %}
>
{{ label }}
</option>
{% endfor %}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/templates/mentorship/become_mentor.html` around lines 19 - 21, The select
for availability doesn't preselect the model default when mentor is absent;
update the selection logic in the availability <select> (id="availability",
name="availability") so it compares each option value to mentor.availability
with a fallback to the model default ("flexible") — e.g. use the Jinja default
filter or an explicit conditional: mark option selected when
(mentor.availability|default('flexible')) == val (or when mentor is missing and
val == "flexible") so new mentor forms preselect the actual default.

</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>
Comment thread
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>
Comment thread
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 %}
Loading
Loading