Skip to content

Commit 32c8cc0

Browse files
committed
feat: add mentorship system with mentor profiles, requests, sessions, and ratings
1 parent c94caf8 commit 32c8cc0

13 files changed

Lines changed: 1064 additions & 0 deletions

web/admin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
ForumTopic,
2929
Goods,
3030
LearningStreak,
31+
MentorProfile,
32+
MentorshipRequest,
33+
MentorshipSession,
3134
MembershipPlan,
3235
MembershipSubscriptionEvent,
3336
Notification,
@@ -610,6 +613,27 @@ class ChallengeSubmissionAdmin(admin.ModelAdmin):
610613

611614
# Unregister the default User admin and register our custom one
612615
admin.site.unregister(User)
616+
@admin.register(MentorProfile)
617+
class MentorProfileAdmin(admin.ModelAdmin):
618+
list_display = ("user", "is_active", "is_free", "hourly_rate", "availability", "created_at")
619+
list_filter = ("is_active", "is_free", "availability")
620+
search_fields = ("user__username", "user__email")
621+
622+
623+
@admin.register(MentorshipRequest)
624+
class MentorshipRequestAdmin(admin.ModelAdmin):
625+
list_display = ("student", "mentor", "status", "created_at")
626+
list_filter = ("status",)
627+
search_fields = ("student__username", "mentor__user__username")
628+
629+
630+
@admin.register(MentorshipSession)
631+
class MentorshipSessionAdmin(admin.ModelAdmin):
632+
list_display = ("mentor", "student", "scheduled_at", "status", "rating")
633+
list_filter = ("status",)
634+
search_fields = ("mentor__user__username", "student__username")
635+
636+
613637
admin.site.register(User, CustomUserAdmin)
614638

615639

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Generated by Django 5.1.15 on 2026-03-22 15:55
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="MentorProfile",
19+
fields=[
20+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
21+
("bio", models.TextField(blank=True)),
22+
("experience_years", models.PositiveIntegerField(default=0)),
23+
("hourly_rate", models.DecimalField(decimal_places=2, default=0.0, max_digits=8)),
24+
("is_free", models.BooleanField(default=True)),
25+
(
26+
"availability",
27+
models.CharField(
28+
choices=[
29+
("weekdays", "Weekdays"),
30+
("weekends", "Weekends"),
31+
("evenings", "Evenings"),
32+
("flexible", "Flexible"),
33+
],
34+
default="flexible",
35+
max_length=20,
36+
),
37+
),
38+
("is_active", models.BooleanField(default=True)),
39+
("created_at", models.DateTimeField(auto_now_add=True)),
40+
("updated_at", models.DateTimeField(auto_now=True)),
41+
("subjects", models.ManyToManyField(blank=True, related_name="mentors", to="web.subject")),
42+
(
43+
"user",
44+
models.OneToOneField(
45+
on_delete=django.db.models.deletion.CASCADE,
46+
related_name="mentor_profile",
47+
to=settings.AUTH_USER_MODEL,
48+
),
49+
),
50+
],
51+
options={
52+
"ordering": ["-created_at"],
53+
},
54+
),
55+
migrations.CreateModel(
56+
name="MentorshipSession",
57+
fields=[
58+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
59+
("scheduled_at", models.DateTimeField()),
60+
("duration_minutes", models.PositiveIntegerField(default=60)),
61+
(
62+
"status",
63+
models.CharField(
64+
choices=[("scheduled", "Scheduled"), ("completed", "Completed"), ("cancelled", "Cancelled")],
65+
default="scheduled",
66+
max_length=10,
67+
),
68+
),
69+
("notes", models.TextField(blank=True)),
70+
(
71+
"rating",
72+
models.PositiveIntegerField(
73+
blank=True,
74+
null=True,
75+
validators=[
76+
django.core.validators.MinValueValidator(1),
77+
django.core.validators.MaxValueValidator(5),
78+
],
79+
),
80+
),
81+
("review", models.TextField(blank=True)),
82+
("created_at", models.DateTimeField(auto_now_add=True)),
83+
("updated_at", models.DateTimeField(auto_now=True)),
84+
(
85+
"mentor",
86+
models.ForeignKey(
87+
on_delete=django.db.models.deletion.CASCADE, related_name="sessions", to="web.mentorprofile"
88+
),
89+
),
90+
(
91+
"student",
92+
models.ForeignKey(
93+
on_delete=django.db.models.deletion.CASCADE,
94+
related_name="mentorship_sessions",
95+
to=settings.AUTH_USER_MODEL,
96+
),
97+
),
98+
(
99+
"subject",
100+
models.ForeignKey(
101+
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="web.subject"
102+
),
103+
),
104+
],
105+
options={
106+
"ordering": ["-scheduled_at"],
107+
},
108+
),
109+
migrations.CreateModel(
110+
name="MentorshipRequest",
111+
fields=[
112+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
113+
("message", models.TextField()),
114+
(
115+
"status",
116+
models.CharField(
117+
choices=[
118+
("pending", "Pending"),
119+
("accepted", "Accepted"),
120+
("declined", "Declined"),
121+
("cancelled", "Cancelled"),
122+
],
123+
default="pending",
124+
max_length=10,
125+
),
126+
),
127+
("created_at", models.DateTimeField(auto_now_add=True)),
128+
("updated_at", models.DateTimeField(auto_now=True)),
129+
(
130+
"mentor",
131+
models.ForeignKey(
132+
on_delete=django.db.models.deletion.CASCADE, related_name="requests", to="web.mentorprofile"
133+
),
134+
),
135+
(
136+
"student",
137+
models.ForeignKey(
138+
on_delete=django.db.models.deletion.CASCADE,
139+
related_name="mentorship_requests",
140+
to=settings.AUTH_USER_MODEL,
141+
),
142+
),
143+
(
144+
"subject",
145+
models.ForeignKey(
146+
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="web.subject"
147+
),
148+
),
149+
],
150+
options={
151+
"ordering": ["-created_at"],
152+
"unique_together": {("mentor", "student")},
153+
},
154+
),
155+
]

web/models.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3176,3 +3176,96 @@ class Meta:
31763176
ordering = ["-last_updated"]
31773177
verbose_name = "Virtual Classroom Whiteboard"
31783178
verbose_name_plural = "Virtual Classroom Whiteboards"
3179+
3180+
3181+
class MentorProfile(models.Model):
3182+
"""A user who offers mentorship in one or more subjects."""
3183+
3184+
AVAILABILITY_CHOICES = [
3185+
("weekdays", "Weekdays"),
3186+
("weekends", "Weekends"),
3187+
("evenings", "Evenings"),
3188+
("flexible", "Flexible"),
3189+
]
3190+
3191+
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="mentor_profile")
3192+
subjects = models.ManyToManyField(Subject, related_name="mentors", blank=True)
3193+
bio = models.TextField(blank=True)
3194+
experience_years = models.PositiveIntegerField(default=0)
3195+
hourly_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0.00)
3196+
is_free = models.BooleanField(default=True)
3197+
availability = models.CharField(max_length=20, choices=AVAILABILITY_CHOICES, default="flexible")
3198+
is_active = models.BooleanField(default=True)
3199+
created_at = models.DateTimeField(auto_now_add=True)
3200+
updated_at = models.DateTimeField(auto_now=True)
3201+
3202+
class Meta:
3203+
ordering = ["-created_at"]
3204+
3205+
def __str__(self) -> str:
3206+
return f"{self.user.username} - Mentor"
3207+
3208+
@property
3209+
def average_rating(self):
3210+
result = self.sessions.filter(rating__isnull=False).aggregate(avg=Avg("rating"))["avg"]
3211+
return round(result, 1) if result else None
3212+
3213+
@property
3214+
def total_sessions(self) -> int:
3215+
return self.sessions.filter(status="completed").count()
3216+
3217+
3218+
class MentorshipRequest(models.Model):
3219+
"""A student request for mentorship."""
3220+
3221+
STATUS_CHOICES = [
3222+
("pending", "Pending"),
3223+
("accepted", "Accepted"),
3224+
("declined", "Declined"),
3225+
("cancelled", "Cancelled"),
3226+
]
3227+
3228+
mentor = models.ForeignKey(MentorProfile, on_delete=models.CASCADE, related_name="requests")
3229+
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="mentorship_requests")
3230+
subject = models.ForeignKey(Subject, on_delete=models.SET_NULL, null=True, blank=True)
3231+
message = models.TextField()
3232+
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="pending")
3233+
created_at = models.DateTimeField(auto_now_add=True)
3234+
updated_at = models.DateTimeField(auto_now=True)
3235+
3236+
class Meta:
3237+
ordering = ["-created_at"]
3238+
unique_together = ["mentor", "student"]
3239+
3240+
def __str__(self) -> str:
3241+
return f"{self.student.username} -> {self.mentor.user.username} ({self.status})"
3242+
3243+
3244+
class MentorshipSession(models.Model):
3245+
"""A scheduled or completed 1-on-1 mentorship session."""
3246+
3247+
STATUS_CHOICES = [
3248+
("scheduled", "Scheduled"),
3249+
("completed", "Completed"),
3250+
("cancelled", "Cancelled"),
3251+
]
3252+
3253+
mentor = models.ForeignKey(MentorProfile, on_delete=models.CASCADE, related_name="sessions")
3254+
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="mentorship_sessions")
3255+
subject = models.ForeignKey(Subject, on_delete=models.SET_NULL, null=True, blank=True)
3256+
scheduled_at = models.DateTimeField()
3257+
duration_minutes = models.PositiveIntegerField(default=60)
3258+
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="scheduled")
3259+
notes = models.TextField(blank=True)
3260+
rating = models.PositiveIntegerField(
3261+
null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(5)]
3262+
)
3263+
review = models.TextField(blank=True)
3264+
created_at = models.DateTimeField(auto_now_add=True)
3265+
updated_at = models.DateTimeField(auto_now=True)
3266+
3267+
class Meta:
3268+
ordering = ["-scheduled_at"]
3269+
3270+
def __str__(self) -> str:
3271+
return f"{self.mentor.user.username} + {self.student.username} @ {self.scheduled_at:%Y-%m-%d %H:%M}"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{% extends "base.html" %}
2+
{% block title %}Become a Mentor{% endblock title %}
3+
{% block content %}
4+
<div class="container mx-auto px-4 py-8 max-w-2xl">
5+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
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>
7+
<form method="post" class="space-y-5">
8+
{% csrf_token %}
9+
<div>
10+
<label for="bio" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bio</label>
11+
<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>
12+
</div>
13+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
14+
<div>
15+
<label for="experience_years" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Years of Experience</label>
16+
<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">
17+
</div>
18+
<div>
19+
<label for="availability" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Availability</label>
20+
<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">
21+
{% for val, label in availability_choices %}<option value="{{ val }}" {% if mentor.availability == val %}selected{% endif %}>{{ label }}</option>{% endfor %}
22+
</select>
23+
</div>
24+
</div>
25+
<div class="flex items-center space-x-4">
26+
<div class="flex items-center">
27+
<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">
28+
<label for="is_free" class="ml-2 text-sm text-gray-700 dark:text-gray-300">Free mentorship</label>
29+
</div>
30+
<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">
31+
</div>
32+
<div>
33+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subjects</label>
34+
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
35+
{% 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 %}
36+
</div>
37+
</div>
38+
<div class="flex items-center space-x-3">
39+
<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>
40+
<a href="{% url 'mentor_list' %}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">Cancel</a>
41+
</div>
42+
</form>
43+
</div>
44+
</div>
45+
{% endblock content %}

0 commit comments

Comments
 (0)