|
| 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. |
0 commit comments