Skip to content

Commit fb7b1be

Browse files
committed
first draft
1 parent de65341 commit fb7b1be

33 files changed

+2741
-920
lines changed

club.md

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
Below is a plan for adding “Clubs” as a parallel, toggle-able entity alongside Courses. We will reuse as much of the existing code, views, and templates as possible, adding only minimal conditionals and a tiny new bit of model definition.
2+
3+
---
4+
5+
## 1. Models
6+
7+
### 1.1. New `ClubCategory` model
8+
9+
tcf_website/models/models.py
10+
(Add immediately after the existing Subdepartment or wherever your models cluster.)
11+
12+
```python
13+
class ClubCategory(models.Model):
14+
# Human name
15+
name = models.CharField(max_length=255, unique=True)
16+
description = models.TextField(blank=True)
17+
# slug for routing in the existing course URL
18+
slug = models.SlugField(max_length=255, unique=True)
19+
20+
def __str__(self):
21+
return self.name
22+
```
23+
24+
- No relationship to Department/Subdepartment.
25+
- `slug` gives us a place to hang on to “mnemonic” in the old course URL.
26+
27+
### 1.2. New `Club` model
28+
29+
tcf_website/models/models.py
30+
31+
```python
32+
class Club(models.Model):
33+
name = models.CharField(max_length=255)
34+
description = models.TextField(blank=True)
35+
category = models.ForeignKey(ClubCategory, on_delete=models.CASCADE)
36+
combined_name = models.CharField(max_length=255, blank=True, editable=False)
37+
38+
def save(self, *args, **kwargs):
39+
# maintain combined_name for trigram search
40+
self.combined_name = self.name
41+
super().save(*args, **kwargs)
42+
43+
def __str__(self):
44+
return self.name
45+
```
46+
47+
- After this, add the same trigram GinIndex you have on Course.combined_mnemonic_number, but pointing at `Club.combined_name`.
48+
- This lets `TrigramSimilarity` work identically.
49+
50+
### 1.3. Extend the existing `Review` model
51+
52+
tcf_website/models/models.py
53+
54+
Find your `class Review(models.Model):` and add:
55+
56+
```python
57+
# Existing fields: course = FK(Course)
58+
club = models.ForeignKey(Club, on_delete=models.CASCADE,
59+
null=True, blank=True)
60+
```
61+
62+
Then adjust any unique‐together or constraints so that a user may have one review per (user, course) OR (user, club). Everything else—votes, pagination, sorting—stays untouched.
63+
64+
---
65+
66+
## 2. Pull “mode=clubs” through your views
67+
68+
We will reuse every URL you already have.
69+
- The existing course‐detail URL is:
70+
`/course/<mnemonic>/<course_number>/`
71+
- In club‐mode we will treat `<mnemonic>` as `ClubCategory.slug` and `<course_number>` as `Club.id`.
72+
73+
### 2.1. Base utility
74+
75+
In a shared module (e.g. at top of each view file) add:
76+
77+
```python
78+
def parse_mode(request):
79+
mode = request.GET.get("mode", "courses")
80+
return mode, (mode == "clubs")
81+
```
82+
83+
### 2.2. Search view
84+
85+
tcf_website/views/search.py
86+
87+
1. At the top of `search(request)`:
88+
89+
```python
90+
mode, is_club = parse_mode(request)
91+
```
92+
93+
2. Build two parallel fetch pipelines:
94+
95+
- **fetch_courses(query, filters)** (already exists)
96+
- **fetch_clubs(query)** ← a new helper that does:
97+
98+
```python
99+
from django.contrib.postgres.search import TrigramSimilarity
100+
from ..models import Club
101+
102+
def fetch_clubs(query):
103+
threshold = 0.15
104+
qs = (Club.objects
105+
.annotate(sim=TrigramSimilarity("combined_name", query))
106+
.annotate(max_similarity=F("sim"))
107+
.filter(max_similarity__gte=threshold)
108+
.order_by("-max_similarity")[:15])
109+
return [
110+
{"id": c.id, "name": c.name, "description": c.description,
111+
"max_similarity": c.max_similarity,
112+
"category_slug": c.category.slug,
113+
"category_name": c.category.name}
114+
for c in qs
115+
]
116+
```
117+
118+
3. In `search()`:
119+
120+
```python
121+
if is_club:
122+
clubs = fetch_clubs(query)
123+
grouped = group_by_club_category(clubs)
124+
total = len(clubs)
125+
else:
126+
courses = fetch_courses(query, filters)
127+
grouped = group_by_dept(courses)
128+
total = len(courses)
129+
```
130+
131+
4. New grouping helper:
132+
133+
```python
134+
def group_by_club_category(clubs):
135+
grouped = {}
136+
for cb in clubs:
137+
slug = cb["category_slug"]
138+
if slug not in grouped:
139+
grouped[slug] = {
140+
"category_name": cb["category_name"],
141+
"category_slug": slug,
142+
"clubs": []
143+
}
144+
grouped[slug]["clubs"].append(cb)
145+
return grouped
146+
```
147+
148+
5. Context:
149+
150+
```python
151+
ctx = {
152+
"mode": mode,
153+
"is_club": is_club,
154+
"query": truncated_query,
155+
...
156+
"total": total,
157+
"grouped": grouped,
158+
# keep your old keys for courses/instructors if !is_club
159+
}
160+
```
161+
162+
### 2.3. Course/Club detail view
163+
164+
tcf_website/views/browse.py → `course_view`
165+
166+
1. At top:
167+
168+
```python
169+
mode, is_club = parse_mode(request)
170+
```
171+
172+
2. Branch:
173+
174+
```python
175+
if is_club:
176+
# 'mnemonic' is actually category_slug, 'course_number' is club.id
177+
club = get_object_or_404(Club, id=course_number,
178+
category__slug=mnemonic.upper())
179+
# Pull reviews exactly as you do for courses, but filter on club=club
180+
paginated_reviews = Review.get_paginated_reviews(
181+
course_id=None,
182+
instructor_id=None,
183+
user=request.user,
184+
page_number=request.GET.get("page", 1),
185+
mode="clubs", # pass through so templates know
186+
club=club.id
187+
)
188+
return render(request, "course/course.html", {
189+
"is_club": True,
190+
"club": club,
191+
"paginated_reviews": paginated_reviews,
192+
"sort_method": request.GET.get("method", ""),
193+
# plus any other bits you need (e.g. data for JS)
194+
})
195+
else:
196+
# <<< existing course logic >>>
197+
return render(request, "course/course.html", {
198+
"is_club": False,
199+
"course": course,
200+
"instructors": instructors,
201+
...
202+
})
203+
```
204+
205+
- We reuse **exactly** the same template name (`course/course.html`) but tell it via `is_club` to switch into club‐rendering mode.
206+
207+
---
208+
209+
## 3. Reviews and Review-form views
210+
211+
Wherever you currently do:
212+
213+
```python
214+
# for new_review, edit_review, check_duplicate, etc.
215+
course_id = request.POST.get("course")
216+
duplicate = Review.objects.filter(user=request.user, course=course_id).exists()
217+
```
218+
219+
you’ll now:
220+
221+
1. parse `mode, is_club = parse_mode(request)`
222+
2. read either `club_id = request.POST.get("club")` *or* the old `course` field
223+
3. branch your Filter/Exists/Save logic on `is_club` and set the FK on the new Review accordingly.
224+
4. carry `mode=clubs` in your redirect so that after POST you come back to the club detail page.
225+
226+
The HTML form (`review_form_content.html`) itself can gain a tiny `if is_club` wrapper around the course‐picker widget, replacing “Subject/Course/Instructor” selects with “Category/Club” selects:
227+
228+
```django
229+
{% if not is_club %}
230+
<!-- existing subject/course/instructor selects -->
231+
{% else %}
232+
<div class="form-row">
233+
<div class="form-group col-sm-6">
234+
<label for="category">Category</label>
235+
<select id="category" name="category" class="form-control" required>
236+
{% for cat in club_categories %}
237+
<option value="{{ cat.id }}" {% if cat.id == form.instance.club.category.id %}selected{% endif %}>
238+
{{ cat.name }}
239+
</option>
240+
{% endfor %}
241+
</select>
242+
</div>
243+
<div class="form-group col-sm-6">
244+
<label for="club">Club</label>
245+
<select id="club" name="club" class="form-control" required>
246+
<option value="{{ form.instance.club.id }}">{{ form.instance.club.name }}</option>
247+
</select>
248+
</div>
249+
</div>
250+
{% endif %}
251+
```
252+
253+
– You won’t need instructor or semester selects for clubs, so you can omit or hide those.
254+
255+
---
256+
257+
## 4. Templates
258+
259+
We’ll continue to reuse **exactly** the same file names.
260+
Everywhere you currently reference `course`, `instructor`, or “Departments,” wrap them in:
261+
262+
```django
263+
{% if not is_club %}
264+
<!-- original markup -->
265+
{% else %}
266+
<!-- minimal club‐specific markup, or include from club/ -->
267+
{% endif %}
268+
```
269+
270+
### 4.1. Create `templates/club/`
271+
272+
#### 4.1.1. `templates/club/club.html`
273+
274+
Copy `course/course.html`, then:
275+
276+
- Change all `course.``club.`
277+
- Remove the instructor‐loop entirely
278+
- Add the “Reviews” and the Q&A tabs from `course/course_professor.html`
279+
280+
### 4.2. Integrate into search & nav
281+
282+
#### In your search bar partial
283+
284+
Add:
285+
286+
```html
287+
<select name="mode" class="form-control">
288+
<option value="courses" {% if not is_club %}selected{% endif %}>Courses</option>
289+
<option value="clubs" {% if is_club %}selected{% endif %}>Clubs</option>
290+
</select>
291+
```
292+
293+
#### In `templates/search/search.html`
294+
295+
- Replace the three‐tab header with either your old “Courses/Instructors/Departments” OR a single “Clubs” panel:
296+
297+
```django
298+
{% if not is_club %}
299+
<!-- existing 3‐tab markup -->
300+
{% else %}
301+
<!-- show just one tab: Clubs -->
302+
<div id="clubs" class="collapse show">
303+
{% include "club/club_search_results.html" %}
304+
</div>
305+
{% endif %}
306+
```
307+
308+
- Write `club_search_results.html` by copying the “Courses” block and swapping `c.mnemonic c.number c.title``club.name` + optional description collapse.
309+
310+
### 4.3. All review templates
311+
312+
In every review include (`review.html`, `reviews.html`, modals, user_reviews, etc.):
313+
314+
- If you render a link back to the detail page, replace the `url 'course' mnemonic=c.mnemonic course_number=c.number` with:
315+
316+
```django
317+
{% if not is_club %}
318+
{% url 'course' mnemonic=review.course.subdepartment.mnemonic course_number=review.course.number %}
319+
{% else %}
320+
{% url 'course' mnemonic=review.club.category.slug course_number=review.club.id %}?mode=clubs
321+
{% endif %}
322+
```
323+
324+
- Everywhere you refer to `review.course` vs `review.instructor`, branch off `if review.club` or `is_club`.
325+
326+
---
327+
328+
## 5. No URL changes
329+
330+
We’re still hitting:
331+
332+
```
333+
/search/?q=Foo&mode=clubs
334+
/course/<slug>/<id>/?mode=clubs
335+
/reviews/new/?mode=clubs
336+
```
337+
338+
The routers in `urls.py` stay untouched.
339+
340+
---
341+
342+
## 6. Final sanity check
343+
344+
1. **Models**
345+
`ClubCategory(slug)`
346+
`Club(category)`
347+
`Review.club`
348+
349+
2. **Views**
350+
`search``fetch_clubs` + `group_by_club_category`
351+
`course_view` → branch on `is_club`
352+
– review‐form + duplicate/zero‐hours checks → branch on `is_club`
353+
354+
3. **Templates**
355+
– Single search bar toggle
356+
– Single URL pattern reused with `?mode=clubs`
357+
`{% if is_club %}{% include "club/…"%}` wrappers in all course pages
358+
– Two new partials: `templates/club/club.html` + `…/club_detail.html`
359+
– Adjust all review‐link URL generations to carry `club` vs `course`.
360+
361+
With this in place, **every** piece of review logic, every JS snippet, every widget, every form, and every template partial is *exactly* the same as before—only switched on a single `mode=clubs` flag.

tcf_website/api/enrollment.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
from django.utils import timezone
1313

1414
from tcf_website.models import Course, Section, SectionEnrollment, Semester
15-
from tcf_website.utils.enrollment import (build_sis_api_url,
16-
format_enrollment_update_message)
15+
from tcf_website.utils.enrollment import (
16+
build_sis_api_url,
17+
format_enrollment_update_message,
18+
)
1719

1820
TIMEOUT = 10
1921
MAX_WORKERS = 5

0 commit comments

Comments
 (0)