Skip to content

Commit 197f165

Browse files
committed
Make ranking weights modifiable via singleton
1 parent d310268 commit 197f165

4 files changed

Lines changed: 224 additions & 29 deletions

File tree

backend/clubs/management/commands/rank.py

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,39 @@
77
from django.db.models import DurationField, ExpressionWrapper, F
88
from django.utils import timezone
99

10-
from clubs.models import Club, ClubFair, EventShowing, Membership
10+
from clubs.models import Club, ClubFair, EventShowing, Membership, RankingWeights
11+
12+
13+
# Default weight values mirroring the historic constant scoring system
14+
DEFAULT_WEIGHTS = {
15+
"inactive_penalty": -1000,
16+
"favorites_per": 1 / 25,
17+
"tags_good": 15,
18+
"tags_many": 7,
19+
"officer_bonus": 15,
20+
"member_base": 10,
21+
"member_per": 1 / 10,
22+
"logo_bonus": 15,
23+
"subtitle_bad": -10,
24+
"subtitle_good": 5,
25+
"images_bonus": 3,
26+
"desc_short": 25,
27+
"desc_med": 10,
28+
"desc_long": 10,
29+
"fair_bonus": 10,
30+
"application_bonus": 25,
31+
"today_event_base": 10,
32+
"today_event_good": 10,
33+
"week_event_base": 5,
34+
"week_event_good": 5,
35+
"email_bonus": 10,
36+
"social_bonus": 10,
37+
"howto_penalty": -30,
38+
"outdated_penalty": -10,
39+
"testimonial_one": 10,
40+
"testimonial_three": 5,
41+
"random_scale": 25,
42+
}
1143

1244

1345
class Command(BaseCommand):
@@ -62,6 +94,16 @@ def set_recruiting_statuses(self):
6294
def rank(self):
6395
count = 0
6496

97+
# Retrieve ranking weights singleton
98+
weights = RankingWeights.get()
99+
100+
def _w(key):
101+
"""Fetch weight: RankingWeights field ➔ DEFAULT_WEIGHTS ➔ 1.0."""
102+
val = getattr(weights, key, None)
103+
if val is not None:
104+
return val
105+
return DEFAULT_WEIGHTS.get(key, 1.0)
106+
65107
clubs = Club.objects.prefetch_related(
66108
"favorite_set",
67109
"tags",
@@ -77,74 +119,74 @@ def rank(self):
77119

78120
# inactive clubs get deprioritized
79121
if not club.active:
80-
ranking -= 1000
122+
ranking += _w("inactive_penalty")
81123

82124
# small points for favorites
83-
ranking += club.favorite_set.count() / 25
125+
ranking += club.favorite_set.count() * _w("favorites_per")
84126

85127
# points for minimum amount of tags
86128
tags = club.tags.count()
87-
if tags >= 3 and tags <= 7:
88-
ranking += 15
129+
if 3 <= tags <= 7:
130+
ranking += _w("tags_good")
89131
elif tags > 7:
90-
ranking += 7
132+
ranking += _w("tags_many")
91133

92134
# lots of points for officers
93135
officers = club.membership_set.filter(
94136
active=True, role__lte=Membership.ROLE_OFFICER
95137
).count()
96138
if officers >= 3:
97-
ranking += 15
139+
ranking += _w("officer_bonus")
98140

99141
# ordinary members give even more points
100142
members = club.membership_set.filter(
101143
active=True, role__gte=Membership.ROLE_MEMBER
102144
).count()
103145
if members >= 3:
104-
ranking += 10
105-
ranking += members / 10
146+
ranking += _w("member_base")
147+
ranking += members * _w("member_per")
106148

107149
# points for logo
108150
if club.image is not None:
109-
ranking += 15
151+
ranking += _w("logo_bonus")
110152

111153
# points for subtitle
112154
subtitle = club.subtitle.strip()
113155
if subtitle.lower() == "your subtitle here":
114-
ranking -= 10
156+
ranking += _w("subtitle_bad")
115157
elif len(subtitle) > 3:
116-
ranking += 5
158+
ranking += _w("subtitle_good")
117159

118160
# images in description? awesome
119161
if "<img" in club.description or "<iframe" in club.description:
120-
ranking += 3
162+
ranking += _w("images_bonus")
121163

122164
# points for longer descriptions
123165
cleaned_description = bleach.clean(
124166
club.description, tags=[], attributes={}, styles=[], strip=True
125167
).strip()
126168

127169
if len(cleaned_description) > 25:
128-
ranking += 25
170+
ranking += _w("desc_short")
129171

130172
if len(cleaned_description) > 250:
131-
ranking += 10
173+
ranking += _w("desc_med")
132174

133175
if len(cleaned_description) > 1000:
134-
ranking += 10
176+
ranking += _w("desc_long")
135177

136178
# points for fair
137179
now = timezone.now()
138180
if ClubFair.objects.filter(
139181
end_time__gte=now, participating_clubs=club
140182
).exists():
141-
ranking += 10
183+
ranking += _w("fair_bonus")
142184

143185
# points for club applications
144186
if club.clubapplication_set.filter(
145187
application_start_time__lte=now, application_end_time__gte=now
146188
).exists():
147-
ranking += 25
189+
ranking += _w("application_bonus")
148190

149191
# points for events
150192
# Get all events with showings today
@@ -172,15 +214,15 @@ def rank(self):
172214
)
173215

174216
if short_showings:
175-
ranking += 10
217+
ranking += _w("today_event_base")
176218
# Check for events with good descriptions and images
177219
if all(
178220
len(e.description) >= 3
179221
and e.description not in {"Replace this description!"}
180222
and e.image is not None
181223
for e in today_events
182224
):
183-
ranking += 10
225+
ranking += _w("today_event_good")
184226

185227
# Get all events with showings in the next week
186228
close_showings = EventShowing.objects.filter(
@@ -207,19 +249,19 @@ def rank(self):
207249
)
208250

209251
if short_showings:
210-
ranking += 5
252+
ranking += _w("week_event_base")
211253
# Check for events with good descriptions and images
212254
if all(
213255
len(e.description) > 3
214256
and e.description not in {"Replace this description!"}
215257
and e.image is not None
216258
for e in close_events
217259
):
218-
ranking += 5
260+
ranking += _w("week_event_good")
219261

220262
# points for public contact email
221263
if club.email and club.email_public:
222-
ranking += 10
264+
ranking += _w("email_bonus")
223265

224266
# points for social links
225267
social_fields = [
@@ -237,28 +279,28 @@ def rank(self):
237279
]
238280
has_fields = [field for field in social_fields if field]
239281
if len(has_fields) >= 2:
240-
ranking += 10
282+
ranking += _w("social_bonus")
241283

242284
# points for how to get involved
243285
if len(club.how_to_get_involved.strip()) <= 3:
244-
ranking -= 30
286+
ranking += _w("howto_penalty")
245287

246288
# points for updated
247289
if club.updated_at < now - datetime.timedelta(days=30 * 8):
248-
ranking -= 10
290+
ranking += _w("outdated_penalty")
249291

250292
# points for testimonials
251293
num_testimonials = club.testimonials.count()
252294
if num_testimonials >= 1:
253-
ranking += 10
295+
ranking += _w("testimonial_one")
254296
if num_testimonials >= 3:
255-
ranking += 5
297+
ranking += _w("testimonial_three")
256298

257299
# random number, mostly shuffles similar clubs with average of 25 points
258300
# but with long right tail to periodically feature less popular clubs
259301
# given ~700 active clubs, multiplier c, expected # clubs with rand > cd
260302
# is 257, 95, 35, 13, 5, 2, 1 for c = 1, 2, 3, 4, 5, 6, 7
261-
ranking += np.random.standard_exponential() * 25
303+
ranking += np.random.standard_exponential() * _w("random_scale")
262304

263305
club.rank = floor(ranking)
264306
club.skip_history_when_saving = True
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 5.2.4 on 2025-08-05 03:51
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('clubs', '0123_category_eligibility_club_category_and_more'),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='RankingWeights',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('inactive_penalty', models.FloatField(default=-1000)),
21+
('favorites_per', models.FloatField(default=0.04)),
22+
('tags_good', models.FloatField(default=15)),
23+
('tags_many', models.FloatField(default=7)),
24+
('officer_bonus', models.FloatField(default=15)),
25+
('member_base', models.FloatField(default=10)),
26+
('member_per', models.FloatField(default=0.1)),
27+
('logo_bonus', models.FloatField(default=15)),
28+
('subtitle_bad', models.FloatField(default=-10)),
29+
('subtitle_good', models.FloatField(default=5)),
30+
('images_bonus', models.FloatField(default=3)),
31+
('desc_short', models.FloatField(default=25)),
32+
('desc_med', models.FloatField(default=10)),
33+
('desc_long', models.FloatField(default=10)),
34+
('fair_bonus', models.FloatField(default=10)),
35+
('application_bonus', models.FloatField(default=25)),
36+
('today_event_base', models.FloatField(default=10)),
37+
('today_event_good', models.FloatField(default=10)),
38+
('week_event_base', models.FloatField(default=5)),
39+
('week_event_good', models.FloatField(default=5)),
40+
('email_bonus', models.FloatField(default=10)),
41+
('social_bonus', models.FloatField(default=10)),
42+
('howto_penalty', models.FloatField(default=-30)),
43+
('outdated_penalty', models.FloatField(default=-10)),
44+
('testimonial_one', models.FloatField(default=10)),
45+
('testimonial_three', models.FloatField(default=5)),
46+
('random_scale', models.FloatField(default=25)),
47+
('updated_at', models.DateTimeField(auto_now=True)),
48+
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_ranking_weights', to=settings.AUTH_USER_MODEL)),
49+
],
50+
),
51+
]

backend/clubs/models.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,62 @@
3939
types_regex = re.compile(r"\s*<!--\s*TYPES:\s*(.*?)\s*-->", re.DOTALL)
4040

4141

42+
class RankingWeights(models.Model):
43+
"""Singleton storing weighting factors for club ranking."""
44+
45+
SINGLETON_PK = 1
46+
47+
# core weights (defaults mirror historical constants)
48+
inactive_penalty = models.FloatField(default=-1000)
49+
favorites_per = models.FloatField(default=1 / 25)
50+
tags_good = models.FloatField(default=15)
51+
tags_many = models.FloatField(default=7)
52+
officer_bonus = models.FloatField(default=15)
53+
member_base = models.FloatField(default=10)
54+
member_per = models.FloatField(default=0.1) # 1/10
55+
logo_bonus = models.FloatField(default=15)
56+
subtitle_bad = models.FloatField(default=-10)
57+
subtitle_good = models.FloatField(default=5)
58+
images_bonus = models.FloatField(default=3)
59+
desc_short = models.FloatField(default=25)
60+
desc_med = models.FloatField(default=10)
61+
desc_long = models.FloatField(default=10)
62+
fair_bonus = models.FloatField(default=10)
63+
application_bonus = models.FloatField(default=25)
64+
today_event_base = models.FloatField(default=10)
65+
today_event_good = models.FloatField(default=10)
66+
week_event_base = models.FloatField(default=5)
67+
week_event_good = models.FloatField(default=5)
68+
email_bonus = models.FloatField(default=10)
69+
social_bonus = models.FloatField(default=10)
70+
howto_penalty = models.FloatField(default=-30)
71+
outdated_penalty = models.FloatField(default=-10)
72+
testimonial_one = models.FloatField(default=10)
73+
testimonial_three = models.FloatField(default=5)
74+
random_scale = models.FloatField(default=25)
75+
76+
updated_at = models.DateTimeField(auto_now=True)
77+
updated_by = models.ForeignKey(
78+
get_user_model(),
79+
on_delete=models.SET_NULL,
80+
null=True,
81+
blank=True,
82+
related_name="modified_ranking_weights",
83+
)
84+
85+
def save(self, *args, **kwargs):
86+
self.pk = self.SINGLETON_PK
87+
super().save(*args, **kwargs)
88+
89+
def delete(self, *args, **kwargs):
90+
raise ValueError("Cannot delete singleton instance")
91+
92+
@classmethod
93+
def get(cls):
94+
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
95+
return obj
96+
97+
4298
def get_mail_type_annotation(name):
4399
"""
44100
Given a template name, return the type annotation metadata.

0 commit comments

Comments
 (0)