|
|
67
|
+ context = {
|
|
|
68
|
+ "nav": _nav_items(),
|
|
|
69
|
+ "counts": {
|
|
|
70
|
+ "profiles": Profile.objects.count(),
|
|
|
71
|
+ "opportunities": Opportunity.objects.count(),
|
|
|
72
|
+ "intro_requests": IntroductionRequest.objects.count(),
|
|
|
73
|
+ },
|
|
|
74
|
+ }
|
|
|
75
|
+ return render(request, "admin_frontend/dashboard.html", context)
|
|
|
76
|
+
|
|
|
77
|
+
|
|
|
78
|
+@staff_member_required
|
|
|
79
|
+@public_route(label="Profiles", order=20, icon="users")
|
|
|
80
|
+def profiles_list(request):
|
|
|
81
|
+ # django-filter FilterSet for profiles
|
|
|
82
|
+ class ProfileFilter(filters.FilterSet):
|
|
|
83
|
+ username = filters.CharFilter(field_name="user__username", lookup_expr="icontains", label="Username")
|
|
|
84
|
+ industry = filters.CharFilter(field_name="industry", lookup_expr="icontains", label="Industry")
|
|
|
85
|
+ is_verified = filters.BooleanFilter(field_name="is_verified", label="Verified")
|
|
|
86
|
+ tag = filters.CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag")
|
|
|
87
|
+
|
|
|
88
|
+ class Meta:
|
|
|
89
|
+ model = Profile
|
|
|
90
|
+ fields = ["username", "industry", "is_verified", "tag"]
|
|
|
91
|
+
|
|
|
92
|
+ def __init__(self, *args, **kwargs):
|
|
|
93
|
+ super().__init__(*args, **kwargs)
|
|
|
94
|
+ try:
|
|
|
95
|
+ from crispy_forms.helper import FormHelper
|
|
|
96
|
+ from crispy_forms.layout import Layout, Field
|
|
|
97
|
+ helper = FormHelper()
|
|
|
98
|
+ helper.form_tag = False
|
|
|
99
|
+ helper.layout = Layout(
|
|
|
100
|
+ Field("username", template="crispy/field_nowrap.html"),
|
|
|
101
|
+ Field("industry", template="crispy/field_nowrap.html"),
|
|
|
102
|
+ Field("tag", template="crispy/field_nowrap.html"),
|
|
|
103
|
+ Field("is_verified", template="crispy/field_nowrap.html"),
|
|
|
104
|
+ )
|
|
|
105
|
+ self.form.helper = helper
|
|
|
106
|
+ except Exception:
|
|
|
107
|
+ # If crispy is unavailable for any reason, fail silently
|
|
|
108
|
+ pass
|
|
|
109
|
+
|
|
|
110
|
+ qs = Profile.objects.select_related("user").all().order_by("-id")
|
|
|
111
|
+
|
|
|
112
|
+ prof_filter = ProfileFilter(request.GET, queryset=qs)
|
|
|
113
|
+ paginator = Paginator(prof_filter.qs, 10)
|
|
|
114
|
+ page = request.GET.get("page")
|
|
|
115
|
+ try:
|
|
|
116
|
+ page_obj = paginator.page(page)
|
|
|
117
|
+ except PageNotAnInteger:
|
|
|
118
|
+ page_obj = paginator.page(1)
|
|
|
119
|
+ except EmptyPage:
|
|
|
120
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
121
|
+
|
|
|
122
|
+ context = {"nav": _nav_items(), "profiles": page_obj.object_list, "page_obj": page_obj, "filter": prof_filter}
|
|
|
123
|
+ return render(request, "admin_frontend/profiles_list.html", context)
|
|
|
124
|
+
|
|
|
125
|
+
|
|
|
126
|
+@staff_member_required
|
|
|
127
|
+@require_POST
|
|
|
128
|
+def profile_toggle_verify(request, pk: int):
|
|
|
129
|
+ profile = get_object_or_404(Profile, pk=pk)
|
|
|
130
|
+ profile.is_verified = not profile.is_verified
|
|
|
131
|
+ profile.save(update_fields=["is_verified"])
|
|
|
132
|
+ messages.success(request, f"Profile '{profile.user.username}' verification toggled.")
|
|
|
133
|
+ return redirect(request.META.get("HTTP_REFERER") or reverse("admin_frontend:profiles_list"))
|
|
|
134
|
+
|
|
|
135
|
+
|
|
|
136
|
+@staff_member_required
|
|
|
137
|
+@public_route(label="Opportunities", order=30, icon="briefcase")
|
|
|
138
|
+def opportunities_list(request):
|
|
|
139
|
+ if request.method == "POST":
|
|
|
140
|
+ form = OpportunityForm(request.POST)
|
|
|
141
|
+ if form.is_valid():
|
|
|
142
|
+ form.save()
|
|
|
143
|
+ messages.success(request, "Opportunity created.")
|
|
|
144
|
+ return redirect("admin_frontend:opportunities_list")
|
|
|
145
|
+ else:
|
|
|
146
|
+ messages.error(request, "Please correct the errors below.")
|
|
|
147
|
+ else:
|
|
|
148
|
+ form = OpportunityForm()
|
|
|
149
|
+
|
|
|
150
|
+ # django-filter FilterSet for opportunities
|
|
|
151
|
+ class OpportunityFilter(filters.FilterSet):
|
|
|
152
|
+ title = filters.CharFilter(field_name="title", lookup_expr="icontains", label="Title")
|
|
|
153
|
+ created_at = filters.DateTimeFromToRangeFilter(
|
|
|
154
|
+ field_name="created_at",
|
|
|
155
|
+ label="Created between",
|
|
|
156
|
+ widget=filters.widgets.RangeWidget(
|
|
|
157
|
+ attrs={
|
|
|
158
|
+ "type": "datetime-local",
|
|
|
159
|
+ "style": "color-scheme: light;",
|
|
|
160
|
+ "class": "border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
|
|
161
|
+ }
|
|
|
162
|
+ ),
|
|
|
163
|
+ )
|
|
|
164
|
+
|
|
|
165
|
+ class Meta:
|
|
|
166
|
+ model = Opportunity
|
|
|
167
|
+ fields = ["title", "created_at"]
|
|
|
168
|
+
|
|
|
169
|
+ opp_qs = Opportunity.objects.order_by("-created_at")
|
|
|
170
|
+ opp_filter = OpportunityFilter(request.GET, queryset=opp_qs)
|
|
|
171
|
+ paginator = Paginator(opp_qs, 10)
|
|
|
172
|
+ page = request.GET.get("page")
|
|
|
173
|
+ try:
|
|
|
174
|
+ page_obj = Paginator(opp_filter.qs, 10).page(page)
|
|
|
175
|
+ except PageNotAnInteger:
|
|
|
176
|
+ page_obj = Paginator(opp_filter.qs, 10).page(1)
|
|
|
177
|
+ except EmptyPage:
|
|
|
178
|
+ # using paginator over filtered qs to get total pages
|
|
|
179
|
+ filtered_paginator = Paginator(opp_filter.qs, 10)
|
|
|
180
|
+ page_obj = filtered_paginator.page(filtered_paginator.num_pages)
|
|
|
181
|
+
|
|
|
182
|
+ context = {
|
|
|
183
|
+ "nav": _nav_items(),
|
|
|
184
|
+ "opportunities": page_obj.object_list,
|
|
|
185
|
+ "page_obj": page_obj,
|
|
|
186
|
+ "form": form,
|
|
|
187
|
+ "filter": opp_filter,
|
|
|
188
|
+ }
|
|
|
189
|
+ return render(request, "admin_frontend/opportunities_list.html", context)
|
|
|
190
|
+
|
|
|
191
|
+
|
|
|
192
|
+@staff_member_required
|
|
|
193
|
+@public_route(label="Intro Requests", order=40, icon="arrows-right-left")
|
|
|
194
|
+def introduction_requests_list(request):
|
|
|
195
|
+ items = IntroductionRequest.objects.select_related("from_user", "to_user").order_by("-created_at")
|
|
|
196
|
+
|
|
|
197
|
+ paginator = Paginator(items, 10)
|
|
|
198
|
+ page = request.GET.get("page")
|
|
|
199
|
+ try:
|
|
|
200
|
+ page_obj = paginator.page(page)
|
|
|
201
|
+ except PageNotAnInteger:
|
|
|
202
|
+ page_obj = paginator.page(1)
|
|
|
203
|
+ except EmptyPage:
|
|
|
204
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
205
|
+
|
|
|
206
|
+ context = {"nav": _nav_items(), "requests": page_obj.object_list, "page_obj": page_obj}
|
|
|
207
|
+ return render(request, "admin_frontend/intro_requests_list.html", context)
|
|
|
208
|
+
|
|
|
209
|
+
|
|
|
210
|
+@staff_member_required
|
|
|
211
|
+@public_route(label="Leaderboard", order=25, icon="trophy")
|
|
|
212
|
+def leaderboard(request):
|
|
|
213
|
+ period = (request.GET.get("period") or "weekly").lower()
|
|
|
214
|
+ try:
|
|
|
215
|
+ limit = min(max(int(request.GET.get("limit", 25)), 1), 200)
|
|
|
216
|
+ except Exception:
|
|
|
217
|
+ limit = 25
|
|
|
218
|
+ try:
|
|
|
219
|
+ offset = max(int(request.GET.get("offset", 0)), 0)
|
|
|
220
|
+ except Exception:
|
|
|
221
|
+ offset = 0
|
|
|
222
|
+
|
|
|
223
|
+ try:
|
|
|
224
|
+ qs = build_leaderboard_queryset(period)
|
|
|
225
|
+ rows = list(qs[offset : offset + limit])
|
|
|
226
|
+ me = qs.filter(id=request.user.id).first()
|
|
|
227
|
+ except Exception:
|
|
|
228
|
+ rows = []
|
|
|
229
|
+ me = None
|
|
|
230
|
+
|
|
|
231
|
+ context = {
|
|
|
232
|
+ "nav": _nav_items(),
|
|
|
233
|
+ "period": period,
|
|
|
234
|
+ "limit": limit,
|
|
|
235
|
+ "offset": offset,
|
|
|
236
|
+ "rows": rows,
|
|
|
237
|
+ "me": me,
|
|
|
238
|
+ "limit_options": [10, 25, 50, 100],
|
|
|
239
|
+ }
|
|
|
240
|
+ return render(request, "admin_frontend/leaderboard.html", context)
|
|
|
241
|
+
|
|
|
242
|
+
|
|
|
243
|
+@staff_member_required
|
|
|
244
|
+@require_POST
|
|
|
245
|
+def intro_request_accept(request, pk: int):
|
|
|
246
|
+ item = get_object_or_404(IntroductionRequest, pk=pk)
|
|
|
247
|
+ if not item.is_accepted:
|
|
|
248
|
+ item.is_accepted = True
|
|
|
249
|
+ item.save(update_fields=["is_accepted"])
|
|
|
250
|
+ messages.success(request, "Introduction request accepted.")
|
|
|
251
|
+ else:
|
|
|
252
|
+ messages.info(request, "Already accepted.")
|
|
|
253
|
+ return redirect(request.META.get("HTTP_REFERER") or reverse("admin_frontend:intro_requests_list"))
|
|
|
254
|
+
|
|
|
255
|
+
|
|
|
256
|
+@staff_member_required
|
|
|
257
|
+def opportunity_edit(request, pk: int):
|
|
|
258
|
+ opp = get_object_or_404(Opportunity, pk=pk)
|
|
|
259
|
+ if request.method == "POST":
|
|
|
260
|
+ form = OpportunityForm(request.POST, instance=opp)
|
|
|
261
|
+ if form.is_valid():
|
|
|
262
|
+ form.save()
|
|
|
263
|
+ messages.success(request, "Opportunity updated.")
|
|
|
264
|
+ return redirect("admin_frontend:opportunity_edit", pk=opp.pk)
|
|
|
265
|
+ else:
|
|
|
266
|
+ form = OpportunityForm(instance=opp)
|
|
|
267
|
+
|
|
|
268
|
+ context = {"nav": _nav_items(), "opportunity": opp, "form": form, "cancel_url": reverse("admin_frontend:opportunities_list")}
|
|
|
269
|
+ return render(request, "admin_frontend/opportunity_form.html", context)
|
|
|
270
|
+
|
|
|
271
|
+
|
|
|
272
|
+@staff_member_required
|
|
|
273
|
+@require_POST
|
|
|
274
|
+def opportunity_delete(request, pk: int):
|
|
|
275
|
+ opp = get_object_or_404(Opportunity, pk=pk)
|
|
|
276
|
+ opp.delete()
|
|
|
277
|
+ messages.success(request, "Opportunity deleted.")
|
|
|
278
|
+ return redirect("admin_frontend:opportunities_list")
|
|
|
279
|
+
|
|
|
280
|
+
|
|
|
281
|
+# Profiles CRUD
|
|
|
282
|
+
|
|
|
283
|
+@staff_member_required
|
|
|
284
|
+def profile_create(request):
|
|
|
285
|
+ if request.method == "POST":
|
|
|
286
|
+ form = ProfileCreateForm(request.POST)
|
|
|
287
|
+ if form.is_valid():
|
|
|
288
|
+ form.save()
|
|
|
289
|
+ messages.success(request, "Profile created.")
|
|
|
290
|
+ return redirect("admin_frontend:profiles_list")
|
|
|
291
|
+ else:
|
|
|
292
|
+ form = ProfileCreateForm()
|
|
|
293
|
+
|
|
|
294
|
+ return render(
|
|
|
295
|
+ request,
|
|
|
296
|
+ "admin_frontend/profile_form.html",
|
|
|
297
|
+ {"nav": _nav_items(), "creating": True, "form": form, "cancel_url": reverse("admin_frontend:profiles_list")},
|
|
|
298
|
+ )
|
|
|
299
|
+
|
|
|
300
|
+
|
|
|
301
|
+@staff_member_required
|
|
|
302
|
+def profile_edit(request, pk: int):
|
|
|
303
|
+ profile = get_object_or_404(Profile, pk=pk)
|
|
|
304
|
+ if request.method == "POST":
|
|
|
305
|
+ form = ProfileEditForm(request.POST, instance=profile)
|
|
|
306
|
+ if form.is_valid():
|
|
|
307
|
+ form.save()
|
|
|
308
|
+ messages.success(request, "Profile updated.")
|
|
|
309
|
+ return redirect("admin_frontend:profile_edit", pk=profile.pk)
|
|
|
310
|
+ else:
|
|
|
311
|
+ form = ProfileEditForm(instance=profile)
|
|
|
312
|
+
|
|
|
313
|
+ # Suggestions for this user (top 5)
|
|
|
314
|
+ try:
|
|
|
315
|
+ user_matches = rank_users_for(profile.user, k=5)
|
|
|
316
|
+ except Exception:
|
|
|
317
|
+ user_matches = []
|
|
|
318
|
+ try:
|
|
|
319
|
+ opp_recs = rank_opportunities_for(profile.user, k=5)
|
|
|
320
|
+ except Exception:
|
|
|
321
|
+ opp_recs = []
|
|
|
322
|
+
|
|
|
323
|
+ return render(
|
|
|
324
|
+ request,
|
|
|
325
|
+ "admin_frontend/profile_form.html",
|
|
|
326
|
+ {
|
|
|
327
|
+ "nav": _nav_items(),
|
|
|
328
|
+ "profile": profile,
|
|
|
329
|
+ "creating": False,
|
|
|
330
|
+ "form": form,
|
|
|
331
|
+ "cancel_url": reverse("admin_frontend:profiles_list"),
|
|
|
332
|
+ "user_matches": user_matches,
|
|
|
333
|
+ "opp_recs": opp_recs,
|
|
|
334
|
+ },
|
|
|
335
|
+ )
|
|
|
336
|
+
|
|
|
337
|
+
|
|
|
338
|
+@staff_member_required
|
|
|
339
|
+@require_POST
|
|
|
340
|
+def profile_delete(request, pk: int):
|
|
|
341
|
+ profile = get_object_or_404(Profile, pk=pk)
|
|
|
342
|
+ profile.delete()
|
|
|
343
|
+ messages.success(request, "Profile deleted.")
|
|
|
344
|
+ return redirect("admin_frontend:profiles_list")
|
|
|
345
|
+
|
|
|
346
|
+
|
|
|
347
|
+# Intro Requests CRUD
|
|
|
348
|
+
|
|
|
349
|
+@staff_member_required
|
|
|
350
|
+def intro_request_create(request):
|
|
|
351
|
+ if request.method == "POST":
|
|
|
352
|
+ form = IntroRequestForm(request.POST)
|
|
|
353
|
+ if form.is_valid():
|
|
|
354
|
+ form.save()
|
|
|
355
|
+ messages.success(request, "Introduction request created.")
|
|
|
356
|
+ return redirect("admin_frontend:intro_requests_list")
|
|
|
357
|
+ else:
|
|
|
358
|
+ form = IntroRequestForm()
|
|
|
359
|
+
|
|
|
360
|
+ return render(
|
|
|
361
|
+ request,
|
|
|
362
|
+ "admin_frontend/intro_request_form.html",
|
|
|
363
|
+ {"nav": _nav_items(), "creating": True, "form": form, "cancel_url": reverse("admin_frontend:intro_requests_list")},
|
|
|
364
|
+ )
|
|
|
365
|
+
|
|
|
366
|
+
|
|
|
367
|
+@staff_member_required
|
|
|
368
|
+def intro_request_edit(request, pk: int):
|
|
|
369
|
+ item = get_object_or_404(IntroductionRequest, pk=pk)
|
|
|
370
|
+ if request.method == "POST":
|
|
|
371
|
+ form = IntroRequestForm(request.POST, instance=item)
|
|
|
372
|
+ if form.is_valid():
|
|
|
373
|
+ form.save(instance=item)
|
|
|
374
|
+ messages.success(request, "Introduction request updated.")
|
|
|
375
|
+ return redirect("admin_frontend:intro_request_edit", pk=item.pk)
|
|
|
376
|
+ else:
|
|
|
377
|
+ form = IntroRequestForm(instance=item)
|
|
|
378
|
+
|
|
|
379
|
+ context = {"nav": _nav_items(), "item": item, "creating": False, "form": form, "cancel_url": reverse("admin_frontend:intro_requests_list")}
|
|
|
380
|
+ return render(request, "admin_frontend/intro_request_form.html", context)
|
|
|
381
|
+
|
|
|
382
|
+
|
|
|
383
|
+@staff_member_required
|
|
|
384
|
+@require_POST
|
|
|
385
|
+def intro_request_delete(request, pk: int):
|
|
|
386
|
+ item = get_object_or_404(IntroductionRequest, pk=pk)
|
|
|
387
|
+ item.delete()
|
|
|
388
|
+ messages.success(request, "Introduction request deleted.")
|
|
|
389
|
+ return redirect("admin_frontend:intro_requests_list")
|
|
|
390
|
+
|
|
|
391
|
+
|
|
|
392
|
+## Recycle Core admin UI moved to recycle_core app
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+from django.contrib import admin
|
|
|
2
|
+
|
|
|
3
|
+# Register your models here.
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+from django.apps import AppConfig
|
|
|
2
|
+
|
|
|
3
|
+
|
|
|
4
|
+class ApiConfig(AppConfig):
|
|
|
5
|
+ default_auto_field = 'django.db.models.BigAutoField'
|
|
|
6
|
+ name = 'api'
|
|
|
7
|
+
|
|
|
8
|
+ def ready(self):
|
|
|
9
|
+ import api.signals
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+from django.core.management.base import BaseCommand
|
|
|
2
|
+from django.contrib.auth.models import User
|
|
|
3
|
+
|
|
|
4
|
+class Command(BaseCommand):
|
|
|
5
|
+ help = 'Creates a superuser.'
|
|
|
6
|
+
|
|
|
7
|
+ def handle(self, *args, **options):
|
|
|
8
|
+ if not User.objects.filter(username='admin').exists():
|
|
|
9
|
+ User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
|
|
10
|
+ self.stdout.write(self.style.SUCCESS('Successfully created superuser.'))
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+import random
|
|
|
2
|
+from django.core.management.base import BaseCommand
|
|
|
3
|
+from django.contrib.auth.models import User
|
|
|
4
|
+from django.db import transaction
|
|
|
5
|
+from django.utils.text import slugify
|
|
|
6
|
+
|
|
|
7
|
+from api.models import Profile, Opportunity, IntroductionRequest
|
|
|
8
|
+
|
|
|
9
|
+
|
|
|
10
|
+class Command(BaseCommand):
|
|
|
11
|
+ help = "Seed demo data: users, profiles, opportunities, and intro requests."
|
|
|
12
|
+
|
|
|
13
|
+ def add_arguments(self, parser):
|
|
|
14
|
+ parser.add_argument(
|
|
|
15
|
+ "--reset",
|
|
|
16
|
+ action="store_true",
|
|
|
17
|
+ help="Delete existing demo data (profiles, opportunities, intro requests) before seeding",
|
|
|
18
|
+ )
|
|
|
19
|
+ parser.add_argument(
|
|
|
20
|
+ "--users",
|
|
|
21
|
+ type=int,
|
|
|
22
|
+ default=6,
|
|
|
23
|
+ help="Approx number of non-admin demo users to ensure exist (default: 6)",
|
|
|
24
|
+ )
|
|
|
25
|
+
|
|
|
26
|
+ @transaction.atomic
|
|
|
27
|
+ def handle(self, *args, **options):
|
|
|
28
|
+ reset = options["--reset"] if "--reset" in options else options.get("reset")
|
|
|
29
|
+ target_users = max(1, int(options.get("users", 6)))
|
|
|
30
|
+
|
|
|
31
|
+ if reset:
|
|
|
32
|
+ IntroductionRequest.objects.all().delete()
|
|
|
33
|
+ Opportunity.objects.all().delete()
|
|
|
34
|
+ Profile.objects.all().delete()
|
|
|
35
|
+ self.stdout.write(self.style.WARNING("Cleared Profiles, Opportunities, and IntroductionRequests."))
|
|
|
36
|
+
|
|
|
37
|
+ # Ensure an admin user exists
|
|
|
38
|
+ admin_user, created = User.objects.get_or_create(username="admin")
|
|
|
39
|
+ if created:
|
|
|
40
|
+ admin_user.set_password("admin123")
|
|
|
41
|
+ admin_user.is_staff = True
|
|
|
42
|
+ admin_user.is_superuser = True
|
|
|
43
|
+ admin_user.email = "admin@example.com"
|
|
|
44
|
+ admin_user.save()
|
|
|
45
|
+ self.stdout.write(self.style.SUCCESS("Created superuser 'admin' (password: admin123)"))
|
|
|
46
|
+ else:
|
|
|
47
|
+ if not admin_user.is_staff or not admin_user.is_superuser:
|
|
|
48
|
+ admin_user.is_staff = True
|
|
|
49
|
+ admin_user.is_superuser = True
|
|
|
50
|
+ admin_user.save(update_fields=["is_staff", "is_superuser"])
|
|
|
51
|
+
|
|
|
52
|
+ # Demo usernames to create/ensure
|
|
|
53
|
+ default_names = [
|
|
|
54
|
+ "alice",
|
|
|
55
|
+ "bob",
|
|
|
56
|
+ "charlie",
|
|
|
57
|
+ "diana",
|
|
|
58
|
+ "eve",
|
|
|
59
|
+ "frank",
|
|
|
60
|
+ "grace",
|
|
|
61
|
+ "heidi",
|
|
|
62
|
+ "ivan",
|
|
|
63
|
+ "judy",
|
|
|
64
|
+ ]
|
|
|
65
|
+
|
|
|
66
|
+ usernames = default_names[: target_users]
|
|
|
67
|
+ created_users = 0
|
|
|
68
|
+ ensured_users = []
|
|
|
69
|
+ for i, uname in enumerate(usernames):
|
|
|
70
|
+ user, u_created = User.objects.get_or_create(username=uname)
|
|
|
71
|
+ if u_created:
|
|
|
72
|
+ user.set_password("password123")
|
|
|
73
|
+ # Make first user staff to access admin_frontend
|
|
|
74
|
+ if i == 0:
|
|
|
75
|
+ user.is_staff = True
|
|
|
76
|
+ user.email = f"{uname}@example.com"
|
|
|
77
|
+ user.save()
|
|
|
78
|
+ created_users += 1
|
|
|
79
|
+ ensured_users.append(user)
|
|
|
80
|
+
|
|
|
81
|
+ # Ensure Profiles for each non-admin user
|
|
|
82
|
+ created_profiles = 0
|
|
|
83
|
+ bios = [
|
|
|
84
|
+ "Passionate about building products and communities.",
|
|
|
85
|
+ "Full-stack developer and coffee enthusiast.",
|
|
|
86
|
+ "Marketer focused on growth and analytics.",
|
|
|
87
|
+ "Product designer who loves simplicity.",
|
|
|
88
|
+ "Data scientist exploring ML and AI.",
|
|
|
89
|
+ "Entrepreneur connecting people and ideas.",
|
|
|
90
|
+ ]
|
|
|
91
|
+ industries_list = [
|
|
|
92
|
+ "Technology",
|
|
|
93
|
+ "Finance",
|
|
|
94
|
+ "Healthcare",
|
|
|
95
|
+ "Education",
|
|
|
96
|
+ "E-commerce",
|
|
|
97
|
+ "Media",
|
|
|
98
|
+ ]
|
|
|
99
|
+
|
|
|
100
|
+ for idx, user in enumerate(ensured_users):
|
|
|
101
|
+ profile, p_created = Profile.objects.get_or_create(
|
|
|
102
|
+ user=user,
|
|
|
103
|
+ defaults={
|
|
|
104
|
+ "bio": bios[idx % len(bios)],
|
|
|
105
|
+ "interests": ", ".join(random.sample(["AI", "Web", "Cloud", "UX", "Data", "DevOps"], 3)),
|
|
|
106
|
+ "industry": industries_list[idx % len(industries_list)],
|
|
|
107
|
+ "is_verified": idx % 2 == 0,
|
|
|
108
|
+ },
|
|
|
109
|
+ )
|
|
|
110
|
+ # Also set taggit tags to mirror interests (best-effort)
|
|
|
111
|
+ try:
|
|
|
112
|
+ if profile and profile.interests:
|
|
|
113
|
+ tokens = [t.strip() for t in profile.interests.split(",") if t.strip()]
|
|
|
114
|
+ if tokens:
|
|
|
115
|
+ profile.tags.set(tokens, clear=True)
|
|
|
116
|
+ except Exception:
|
|
|
117
|
+ pass
|
|
|
118
|
+ if p_created:
|
|
|
119
|
+ created_profiles += 1
|
|
|
120
|
+
|
|
|
121
|
+ # Ensure a few Opportunities
|
|
|
122
|
+ opp_titles = [
|
|
|
123
|
+ "Build Analytics Dashboard",
|
|
|
124
|
+ "Launch Marketing Campaign",
|
|
|
125
|
+ "Design Mobile App UI",
|
|
|
126
|
+ "Migrate to Cloud Infrastructure",
|
|
|
127
|
+ "Implement Recommendation Engine",
|
|
|
128
|
+ ]
|
|
|
129
|
+ created_opps = 0
|
|
|
130
|
+ for title in opp_titles:
|
|
|
131
|
+ opp, o_created = Opportunity.objects.get_or_create(
|
|
|
132
|
+ title=title,
|
|
|
133
|
+ defaults={
|
|
|
134
|
+ "description": f"Opportunity to {title.lower()}. Help wanted from motivated contributors.",
|
|
|
135
|
+ },
|
|
|
136
|
+ )
|
|
|
137
|
+ if o_created:
|
|
|
138
|
+ created_opps += 1
|
|
|
139
|
+
|
|
|
140
|
+ # Ensure a few IntroductionRequests between different users
|
|
|
141
|
+ created_intros = 0
|
|
|
142
|
+ if len(ensured_users) >= 2:
|
|
|
143
|
+ pairs = []
|
|
|
144
|
+ for i in range(0, min(4, len(ensured_users) - 1)):
|
|
|
145
|
+ a = ensured_users[i]
|
|
|
146
|
+ b = ensured_users[-(i + 1)]
|
|
|
147
|
+ if a != b:
|
|
|
148
|
+ pairs.append((a, b))
|
|
|
149
|
+ for (a, b) in pairs:
|
|
|
150
|
+ msg = f"Hi {b.username}, would love to connect about {random.choice(['AI', 'design', 'growth', 'data'])}."
|
|
|
151
|
+ exists = IntroductionRequest.objects.filter(from_user=a, to_user=b, message=msg).exists()
|
|
|
152
|
+ if not exists:
|
|
|
153
|
+ IntroductionRequest.objects.create(
|
|
|
154
|
+ from_user=a, to_user=b, message=msg, is_accepted=random.choice([False, True])
|
|
|
155
|
+ )
|
|
|
156
|
+ created_intros += 1
|
|
|
157
|
+
|
|
|
158
|
+ self.stdout.write(
|
|
|
159
|
+ self.style.SUCCESS(
|
|
|
160
|
+ f"Seed complete: users(+{created_users}), profiles(+{created_profiles}), opportunities(+{created_opps}), intro_requests(+{created_intros})"
|
|
|
161
|
+ )
|
|
|
162
|
+ )
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+# Generated by Django 4.2.23 on 2025-08-26 13:50
|
|
|
2
|
+
|
|
|
3
|
+from django.conf import settings
|
|
|
4
|
+from django.db import migrations, models
|
|
|
5
|
+import django.db.models.deletion
|
|
|
6
|
+
|
|
|
7
|
+
|
|
|
8
|
+class Migration(migrations.Migration):
|
|
|
9
|
+
|
|
|
10
|
+ initial = True
|
|
|
11
|
+
|
|
|
12
|
+ dependencies = [
|
|
|
13
|
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
|
14
|
+ ]
|
|
|
15
|
+
|
|
|
16
|
+ operations = [
|
|
|
17
|
+ migrations.CreateModel(
|
|
|
18
|
+ name='Opportunity',
|
|
|
19
|
+ fields=[
|
|
|
20
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
21
|
+ ('title', models.CharField(max_length=255)),
|
|
|
22
|
+ ('description', models.TextField()),
|
|
|
23
|
+ ('created_at', models.DateTimeField(auto_now_add=True)),
|
|
|
24
|
+ ],
|
|
|
25
|
+ ),
|
|
|
26
|
+ migrations.CreateModel(
|
|
|
27
|
+ name='Profile',
|
|
|
28
|
+ fields=[
|
|
|
29
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
30
|
+ ('bio', models.TextField(blank=True)),
|
|
|
31
|
+ ('interests', models.CharField(blank=True, max_length=255)),
|
|
|
32
|
+ ('industry', models.CharField(blank=True, max_length=255)),
|
|
|
33
|
+ ('is_verified', models.BooleanField(default=False)),
|
|
|
34
|
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
|
35
|
+ ],
|
|
|
36
|
+ ),
|
|
|
37
|
+ migrations.CreateModel(
|
|
|
38
|
+ name='IntroductionRequest',
|
|
|
39
|
+ fields=[
|
|
|
40
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
41
|
+ ('message', models.TextField()),
|
|
|
42
|
+ ('is_accepted', models.BooleanField(default=False)),
|
|
|
43
|
+ ('created_at', models.DateTimeField(auto_now_add=True)),
|
|
|
44
|
+ ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_requests', to=settings.AUTH_USER_MODEL)),
|
|
|
45
|
+ ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_requests', to=settings.AUTH_USER_MODEL)),
|
|
|
46
|
+ ],
|
|
|
47
|
+ ),
|
|
|
48
|
+ ]
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+# Generated by Django 4.2.23 on 2025-08-30 14:38
|
|
|
2
|
+
|
|
|
3
|
+from django.db import migrations
|
|
|
4
|
+import taggit.managers
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+class Migration(migrations.Migration):
|
|
|
8
|
+
|
|
|
9
|
+ dependencies = [
|
|
|
10
|
+ ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
|
|
11
|
+ ('api', '0001_initial'),
|
|
|
12
|
+ ]
|
|
|
13
|
+
|
|
|
14
|
+ operations = [
|
|
|
15
|
+ migrations.AddField(
|
|
|
16
|
+ model_name='profile',
|
|
|
17
|
+ name='tags',
|
|
|
18
|
+ field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
|
|
|
19
|
+ ),
|
|
|
20
|
+ ]
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+# Generated by Django 4.2.23 on 2025-08-31 15:01
|
|
|
2
|
+
|
|
|
3
|
+from django.conf import settings
|
|
|
4
|
+from django.db import migrations, models
|
|
|
5
|
+import django.db.models.deletion
|
|
|
6
|
+
|
|
|
7
|
+
|
|
|
8
|
+class Migration(migrations.Migration):
|
|
|
9
|
+
|
|
|
10
|
+ dependencies = [
|
|
|
11
|
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
|
12
|
+ ('api', '0002_profile_tags'),
|
|
|
13
|
+ ]
|
|
|
14
|
+
|
|
|
15
|
+ operations = [
|
|
|
16
|
+ migrations.CreateModel(
|
|
|
17
|
+ name='BenefitEvent',
|
|
|
18
|
+ fields=[
|
|
|
19
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
20
|
+ ('points', models.IntegerField()),
|
|
|
21
|
+ ('kind', models.CharField(choices=[('accepted_answer', 'Accepted Answer'), ('review_helpful', 'Review Marked Helpful'), ('referral', 'Referral'), ('recommendation', 'Recommendation')], max_length=32)),
|
|
|
22
|
+ ('meta', models.JSONField(blank=True, default=dict)),
|
|
|
23
|
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
|
24
|
+ ('benefactor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='benefit_given', to=settings.AUTH_USER_MODEL)),
|
|
|
25
|
+ ('beneficiary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='benefit_received', to=settings.AUTH_USER_MODEL)),
|
|
|
26
|
+ ],
|
|
|
27
|
+ options={
|
|
|
28
|
+ 'ordering': ('-created_at', '-id'),
|
|
|
29
|
+ 'indexes': [models.Index(fields=['benefactor', 'created_at'], name='api_benefit_benefac_a07d7f_idx'), models.Index(fields=['beneficiary', 'created_at'], name='api_benefit_benefic_2819d8_idx'), models.Index(fields=['kind', 'created_at'], name='api_benefit_kind_bdc89b_idx')],
|
|
|
30
|
+ },
|
|
|
31
|
+ ),
|
|
|
32
|
+ ]
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+# Generated by Django 4.2.23 on 2025-08-31 15:08
|
|
|
2
|
+
|
|
|
3
|
+from django.db import migrations, models
|
|
|
4
|
+import django.utils.timezone
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+class Migration(migrations.Migration):
|
|
|
8
|
+
|
|
|
9
|
+ dependencies = [
|
|
|
10
|
+ ('api', '0003_benefitevent'),
|
|
|
11
|
+ ]
|
|
|
12
|
+
|
|
|
13
|
+ operations = [
|
|
|
14
|
+ migrations.AlterField(
|
|
|
15
|
+ model_name='benefitevent',
|
|
|
16
|
+ name='created_at',
|
|
|
17
|
+ field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
|
|
|
18
|
+ ),
|
|
|
19
|
+ ]
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+from django.db import models
|
|
|
2
|
+from django.contrib.auth.models import User
|
|
|
3
|
+from django.core.exceptions import ValidationError
|
|
|
4
|
+from django.utils import timezone
|
|
|
5
|
+from taggit.managers import TaggableManager
|
|
|
6
|
+
|
|
|
7
|
+class Profile(models.Model):
|
|
|
8
|
+ user = models.OneToOneField(User, on_delete=models.CASCADE)
|
|
|
9
|
+ bio = models.TextField(blank=True)
|
|
|
10
|
+ interests = models.CharField(max_length=255, blank=True)
|
|
|
11
|
+ industry = models.CharField(max_length=255, blank=True)
|
|
|
12
|
+ is_verified = models.BooleanField(default=False)
|
|
|
13
|
+ tags = TaggableManager(blank=True)
|
|
|
14
|
+
|
|
|
15
|
+ def __str__(self):
|
|
|
16
|
+ return self.user.username
|
|
|
17
|
+
|
|
|
18
|
+class Opportunity(models.Model):
|
|
|
19
|
+ title = models.CharField(max_length=255)
|
|
|
20
|
+ description = models.TextField()
|
|
|
21
|
+ created_at = models.DateTimeField(auto_now_add=True)
|
|
|
22
|
+
|
|
|
23
|
+ def __str__(self):
|
|
|
24
|
+ return self.title
|
|
|
25
|
+
|
|
|
26
|
+class IntroductionRequest(models.Model):
|
|
|
27
|
+ from_user = models.ForeignKey(User, related_name='sent_requests', on_delete=models.CASCADE)
|
|
|
28
|
+ to_user = models.ForeignKey(User, related_name='received_requests', on_delete=models.CASCADE)
|
|
|
29
|
+ message = models.TextField()
|
|
|
30
|
+ is_accepted = models.BooleanField(default=False)
|
|
|
31
|
+ created_at = models.DateTimeField(auto_now_add=True)
|
|
|
32
|
+
|
|
|
33
|
+ def __str__(self):
|
|
|
34
|
+ return f'Introduction request from {self.from_user} to {self.to_user}'
|
|
|
35
|
+
|
|
|
36
|
+
|
|
|
37
|
+class BenefitEvent(models.Model):
|
|
|
38
|
+ """Immutable ledger of user-to-user benefit/help actions.
|
|
|
39
|
+
|
|
|
40
|
+ benefactor: the user who provided value/help.
|
|
|
41
|
+ beneficiary: the user who received value.
|
|
|
42
|
+ points: positive integer weight for the action.
|
|
|
43
|
+ kind: action type (accepted_answer, review_helpful, referral, recommendation).
|
|
|
44
|
+ meta: optional JSON metadata (e.g., related object ids).
|
|
|
45
|
+ """
|
|
|
46
|
+
|
|
|
47
|
+ KIND_ACCEPTED_ANSWER = "accepted_answer"
|
|
|
48
|
+ KIND_REVIEW_HELPFUL = "review_helpful"
|
|
|
49
|
+ KIND_REFERRAL = "referral"
|
|
|
50
|
+ KIND_RECOMMENDATION = "recommendation"
|
|
|
51
|
+
|
|
|
52
|
+ KIND_CHOICES = (
|
|
|
53
|
+ (KIND_ACCEPTED_ANSWER, "Accepted Answer"),
|
|
|
54
|
+ (KIND_REVIEW_HELPFUL, "Review Marked Helpful"),
|
|
|
55
|
+ (KIND_REFERRAL, "Referral"),
|
|
|
56
|
+ (KIND_RECOMMENDATION, "Recommendation"),
|
|
|
57
|
+ )
|
|
|
58
|
+
|
|
|
59
|
+ benefactor = models.ForeignKey(
|
|
|
60
|
+ User, on_delete=models.CASCADE, related_name="benefit_given"
|
|
|
61
|
+ )
|
|
|
62
|
+ beneficiary = models.ForeignKey(
|
|
|
63
|
+ User, on_delete=models.CASCADE, related_name="benefit_received"
|
|
|
64
|
+ )
|
|
|
65
|
+ points = models.IntegerField()
|
|
|
66
|
+ kind = models.CharField(max_length=32, choices=KIND_CHOICES)
|
|
|
67
|
+ meta = models.JSONField(default=dict, blank=True)
|
|
|
68
|
+ # Use default=timezone.now so tests and backfills can set explicit timestamps
|
|
|
69
|
+ created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
|
|
70
|
+
|
|
|
71
|
+ class Meta:
|
|
|
72
|
+ indexes = [
|
|
|
73
|
+ models.Index(fields=["benefactor", "created_at"]),
|
|
|
74
|
+ models.Index(fields=["beneficiary", "created_at"]),
|
|
|
75
|
+ models.Index(fields=["kind", "created_at"]),
|
|
|
76
|
+ ]
|
|
|
77
|
+ ordering = ("-created_at", "-id")
|
|
|
78
|
+
|
|
|
79
|
+ def clean(self):
|
|
|
80
|
+ if self.benefactor_id and self.beneficiary_id and self.benefactor_id == self.beneficiary_id:
|
|
|
81
|
+ raise ValidationError("Self-benefit is not allowed.")
|
|
|
82
|
+ if self.points is None or self.points <= 0:
|
|
|
83
|
+ raise ValidationError("Points must be a positive integer.")
|
|
|
84
|
+
|
|
|
85
|
+ def save(self, *args, **kwargs):
|
|
|
86
|
+ # Ensure validation always runs to enforce integrity
|
|
|
87
|
+ self.full_clean()
|
|
|
88
|
+ return super().save(*args, **kwargs)
|
|
|
89
|
+
|
|
|
90
|
+ def __str__(self):
|
|
|
91
|
+ return f"{self.kind} +{self.points} from {self.benefactor_id} to {self.beneficiary_id}"
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+from rest_framework import serializers
|
|
|
2
|
+from django.contrib.auth.models import User
|
|
|
3
|
+from .models import Profile, Opportunity, IntroductionRequest
|
|
|
4
|
+from django.contrib.auth.models import User
|
|
|
5
|
+
|
|
|
6
|
+class UserSerializer(serializers.ModelSerializer):
|
|
|
7
|
+ class Meta:
|
|
|
8
|
+ model = User
|
|
|
9
|
+ fields = ['id', 'username', 'email']
|
|
|
10
|
+
|
|
|
11
|
+class ProfileSerializer(serializers.ModelSerializer):
|
|
|
12
|
+ user = UserSerializer(read_only=True)
|
|
|
13
|
+ tags = serializers.SerializerMethodField()
|
|
|
14
|
+
|
|
|
15
|
+ class Meta:
|
|
|
16
|
+ model = Profile
|
|
|
17
|
+ fields = ['id', 'user', 'bio', 'interests', 'industry', 'is_verified', 'tags']
|
|
|
18
|
+
|
|
|
19
|
+ def get_tags(self, obj: Profile):
|
|
|
20
|
+ try:
|
|
|
21
|
+ return list(obj.tags.names())
|
|
|
22
|
+ except Exception:
|
|
|
23
|
+ return []
|
|
|
24
|
+
|
|
|
25
|
+
|
|
|
26
|
+
|
|
|
27
|
+class OpportunitySerializer(serializers.ModelSerializer):
|
|
|
28
|
+ class Meta:
|
|
|
29
|
+ model = Opportunity
|
|
|
30
|
+ fields = ['id', 'title', 'description', 'created_at']
|
|
|
31
|
+
|
|
|
32
|
+class IntroductionRequestSerializer(serializers.ModelSerializer):
|
|
|
33
|
+ from_user = UserSerializer(read_only=True)
|
|
|
34
|
+ to_user = UserSerializer(read_only=True)
|
|
|
35
|
+
|
|
|
36
|
+ class Meta:
|
|
|
37
|
+ model = IntroductionRequest
|
|
|
38
|
+ fields = ['id', 'from_user', 'to_user', 'message', 'is_accepted', 'created_at']
|
|
|
39
|
+
|
|
|
40
|
+
|
|
|
41
|
+class LeaderRowSerializer(serializers.Serializer):
|
|
|
42
|
+ rank = serializers.IntegerField()
|
|
|
43
|
+ user_id = serializers.IntegerField(source='id')
|
|
|
44
|
+ username = serializers.CharField()
|
|
|
45
|
+ points = serializers.IntegerField(source='points_given')
|
|
|
46
|
+ last_help_at = serializers.DateTimeField(allow_null=True)
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from typing import Optional
|
|
|
4
|
+
|
|
|
5
|
+from django.contrib.auth import get_user_model
|
|
|
6
|
+from django.db.models import Sum, F, Max, Q, Window
|
|
|
7
|
+from django.db.models.functions import Rank
|
|
|
8
|
+from django.utils import timezone
|
|
|
9
|
+
|
|
|
10
|
+from api.models import BenefitEvent
|
|
|
11
|
+
|
|
|
12
|
+
|
|
|
13
|
+User = get_user_model()
|
|
|
14
|
+
|
|
|
15
|
+
|
|
|
16
|
+# Default weights per action kind
|
|
|
17
|
+BENEFIT_WEIGHTS = {
|
|
|
18
|
+ BenefitEvent.KIND_ACCEPTED_ANSWER: 10,
|
|
|
19
|
+ BenefitEvent.KIND_REVIEW_HELPFUL: 3,
|
|
|
20
|
+ BenefitEvent.KIND_REFERRAL: 20,
|
|
|
21
|
+ BenefitEvent.KIND_RECOMMENDATION: 5,
|
|
|
22
|
+}
|
|
|
23
|
+
|
|
|
24
|
+
|
|
|
25
|
+def log_benefit_event(
|
|
|
26
|
+ *,
|
|
|
27
|
+ benefactor: User,
|
|
|
28
|
+ beneficiary: User,
|
|
|
29
|
+ kind: str,
|
|
|
30
|
+ points: Optional[int] = None,
|
|
|
31
|
+ meta: Optional[dict] = None,
|
|
|
32
|
+) -> BenefitEvent:
|
|
|
33
|
+ if points is None:
|
|
|
34
|
+ points = BENEFIT_WEIGHTS.get(kind, 1)
|
|
|
35
|
+ event = BenefitEvent(
|
|
|
36
|
+ benefactor=benefactor,
|
|
|
37
|
+ beneficiary=beneficiary,
|
|
|
38
|
+ kind=kind,
|
|
|
39
|
+ points=points,
|
|
|
40
|
+ meta=meta or {},
|
|
|
41
|
+ )
|
|
|
42
|
+ event.save()
|
|
|
43
|
+ return event
|
|
|
44
|
+
|
|
|
45
|
+
|
|
|
46
|
+def _period_start(period: str):
|
|
|
47
|
+ period = (period or "").lower()
|
|
|
48
|
+ if period in ("all", "all_time", "alltime", ""):
|
|
|
49
|
+ return None
|
|
|
50
|
+ now = timezone.now()
|
|
|
51
|
+ if period in ("week", "weekly", "7d"):
|
|
|
52
|
+ return now - timezone.timedelta(days=7)
|
|
|
53
|
+ if period in ("month", "monthly", "30d"):
|
|
|
54
|
+ return now - timezone.timedelta(days=30)
|
|
|
55
|
+ # default fallback: weekly
|
|
|
56
|
+ return now - timezone.timedelta(days=7)
|
|
|
57
|
+
|
|
|
58
|
+
|
|
|
59
|
+def build_leaderboard_queryset(period: str = "weekly"):
|
|
|
60
|
+ start = _period_start(period)
|
|
|
61
|
+ pf = Q()
|
|
|
62
|
+ if start is not None:
|
|
|
63
|
+ pf = Q(benefit_given__created_at__gte=start)
|
|
|
64
|
+ qs = (
|
|
|
65
|
+ User.objects
|
|
|
66
|
+ .annotate(
|
|
|
67
|
+ points_given=Sum('benefit_given__points', filter=pf),
|
|
|
68
|
+ last_help_at=Max('benefit_given__created_at', filter=pf),
|
|
|
69
|
+ )
|
|
|
70
|
+ )
|
|
|
71
|
+ if start is not None:
|
|
|
72
|
+ qs = qs.filter(benefit_given__created_at__gte=start)
|
|
|
73
|
+ qs = (
|
|
|
74
|
+ qs.filter(points_given__gt=0)
|
|
|
75
|
+ .annotate(
|
|
|
76
|
+ rank=Window(
|
|
|
77
|
+ expression=Rank(),
|
|
|
78
|
+ order_by=(
|
|
|
79
|
+ F('points_given').desc(nulls_last=True),
|
|
|
80
|
+ F('last_help_at').asc(nulls_last=True),
|
|
|
81
|
+ F('id').asc(),
|
|
|
82
|
+ ),
|
|
|
83
|
+ )
|
|
|
84
|
+ )
|
|
|
85
|
+ .order_by('rank')
|
|
|
86
|
+ )
|
|
|
87
|
+ return qs
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from collections import Counter
|
|
|
4
|
+from dataclasses import dataclass
|
|
|
5
|
+from typing import Iterable, List, Sequence, Tuple
|
|
|
6
|
+
|
|
|
7
|
+from django.contrib.auth.models import User
|
|
|
8
|
+from django.db.models import Q
|
|
|
9
|
+from django.utils import timezone
|
|
|
10
|
+
|
|
|
11
|
+from api.models import IntroductionRequest, Opportunity, Profile
|
|
|
12
|
+
|
|
|
13
|
+
|
|
|
14
|
+def _now():
|
|
|
15
|
+ return timezone.now()
|
|
|
16
|
+
|
|
|
17
|
+
|
|
|
18
|
+def tokenize_interests_text(text: str | None) -> set[str]:
|
|
|
19
|
+ if not text:
|
|
|
20
|
+ return set()
|
|
|
21
|
+ raw = [t.strip().lower() for part in text.split(",") for t in part.split()] # type: ignore
|
|
|
22
|
+ return {t for t in raw if t}
|
|
|
23
|
+
|
|
|
24
|
+
|
|
|
25
|
+def profile_interests(profile: Profile) -> set[str]:
|
|
|
26
|
+ # Prefer taggit tags when available, fall back to comma-separated string
|
|
|
27
|
+ try:
|
|
|
28
|
+ tag_names = set(t.lower() for t in profile.tags.names())
|
|
|
29
|
+ except Exception:
|
|
|
30
|
+ tag_names = set()
|
|
|
31
|
+ if tag_names:
|
|
|
32
|
+ return tag_names
|
|
|
33
|
+ return tokenize_interests_text(profile.interests)
|
|
|
34
|
+
|
|
|
35
|
+
|
|
|
36
|
+def tokenize_text(text: str | None) -> List[str]:
|
|
|
37
|
+ if not text:
|
|
|
38
|
+ return []
|
|
|
39
|
+ clean = "".join(ch.lower() if ch.isalnum() or ch.isspace() else " " for ch in text)
|
|
|
40
|
+ return [t for t in clean.split() if t]
|
|
|
41
|
+
|
|
|
42
|
+
|
|
|
43
|
+def jaccard(a: Iterable[str], b: Iterable[str]) -> float:
|
|
|
44
|
+ sa, sb = set(a), set(b)
|
|
|
45
|
+ if not sa and not sb:
|
|
|
46
|
+ return 0.0
|
|
|
47
|
+ inter = len(sa & sb)
|
|
|
48
|
+ union = len(sa | sb)
|
|
|
49
|
+ return inter / union if union else 0.0
|
|
|
50
|
+
|
|
|
51
|
+
|
|
|
52
|
+def cosine(a: Sequence[str], b: Sequence[str]) -> float:
|
|
|
53
|
+ if not a or not b:
|
|
|
54
|
+ return 0.0
|
|
|
55
|
+ ca, cb = Counter(a), Counter(b)
|
|
|
56
|
+ # dot product
|
|
|
57
|
+ dot = sum(ca[t] * cb.get(t, 0) for t in ca)
|
|
|
58
|
+ if dot == 0:
|
|
|
59
|
+ return 0.0
|
|
|
60
|
+ import math
|
|
|
61
|
+
|
|
|
62
|
+ na = math.sqrt(sum(v * v for v in ca.values()))
|
|
|
63
|
+ nb = math.sqrt(sum(v * v for v in cb.values()))
|
|
|
64
|
+ denom = na * nb
|
|
|
65
|
+ return (dot / denom) if denom else 0.0
|
|
|
66
|
+
|
|
|
67
|
+
|
|
|
68
|
+def time_decay(ts, half_life_days: float = 7.0) -> float:
|
|
|
69
|
+ if not ts:
|
|
|
70
|
+ return 0.0
|
|
|
71
|
+ import math
|
|
|
72
|
+
|
|
|
73
|
+ dt = (_now() - ts).total_seconds()
|
|
|
74
|
+ half_life = half_life_days * 24 * 3600.0
|
|
|
75
|
+ if dt <= 0:
|
|
|
76
|
+ return 1.0
|
|
|
77
|
+ return math.pow(0.5, dt / half_life)
|
|
|
78
|
+
|
|
|
79
|
+
|
|
|
80
|
+def neighbor_set(u: User) -> set[int]:
|
|
|
81
|
+ # Users this user has interacted with via introduction requests
|
|
|
82
|
+ ids = set(
|
|
|
83
|
+ IntroductionRequest.objects.filter(Q(from_user=u) | Q(to_user=u))
|
|
|
84
|
+ .values_list("from_user_id", "to_user_id")
|
|
|
85
|
+ )
|
|
|
86
|
+ # Flatten and drop self id
|
|
|
87
|
+ flat = {i for pair in ids for i in pair if i and i != u.id}
|
|
|
88
|
+ return flat
|
|
|
89
|
+
|
|
|
90
|
+
|
|
|
91
|
+def graph_similarity(u: User, v: User) -> float:
|
|
|
92
|
+ nu, nv = neighbor_set(u), neighbor_set(v)
|
|
|
93
|
+ return jaccard(nu, nv)
|
|
|
94
|
+
|
|
|
95
|
+
|
|
|
96
|
+def has_existing_request(u: User, v: User) -> bool:
|
|
|
97
|
+ return IntroductionRequest.objects.filter(
|
|
|
98
|
+ (Q(from_user=u, to_user=v) | Q(from_user=v, to_user=u))
|
|
|
99
|
+ ).exists()
|
|
|
100
|
+
|
|
|
101
|
+
|
|
|
102
|
+@dataclass
|
|
|
103
|
+class RankedProfile:
|
|
|
104
|
+ profile: Profile
|
|
|
105
|
+ score: float
|
|
|
106
|
+
|
|
|
107
|
+
|
|
|
108
|
+def rank_users_for(user: User, k: int = 20) -> List[RankedProfile]:
|
|
|
109
|
+ try:
|
|
|
110
|
+ me_profile = Profile.objects.select_related("user").get(user=user)
|
|
|
111
|
+ except Profile.DoesNotExist:
|
|
|
112
|
+ me_profile = Profile(user=user) # empty defaults
|
|
|
113
|
+
|
|
|
114
|
+ my_interests = profile_interests(me_profile)
|
|
|
115
|
+ ranked: List[Tuple[float, Profile]] = []
|
|
|
116
|
+
|
|
|
117
|
+ qs = (
|
|
|
118
|
+ Profile.objects.select_related("user")
|
|
|
119
|
+ .exclude(user=user)
|
|
|
120
|
+ )
|
|
|
121
|
+
|
|
|
122
|
+ for p in qs.iterator():
|
|
|
123
|
+ if has_existing_request(user, p.user):
|
|
|
124
|
+ continue
|
|
|
125
|
+
|
|
|
126
|
+ jac = jaccard(my_interests, profile_interests(p))
|
|
|
127
|
+ # Industry match: exact string match and non-empty
|
|
|
128
|
+ industry_match = 1.0 if (me_profile.industry and me_profile.industry == p.industry) else 0.0
|
|
|
129
|
+ ver = 1.0 if p.is_verified else 0.0
|
|
|
130
|
+ gsim = graph_similarity(user, p.user)
|
|
|
131
|
+
|
|
|
132
|
+ # Candidate activity recency: last accepted intro involving candidate
|
|
|
133
|
+ last_acc = (
|
|
|
134
|
+ IntroductionRequest.objects.filter(
|
|
|
135
|
+ Q(from_user=p.user) | Q(to_user=p.user), is_accepted=True
|
|
|
136
|
+ )
|
|
|
137
|
+ .order_by("-created_at")
|
|
|
138
|
+ .values_list("created_at", flat=True)
|
|
|
139
|
+ .first()
|
|
|
140
|
+ )
|
|
|
141
|
+ rec = time_decay(last_acc) if last_acc else 0.0
|
|
|
142
|
+
|
|
|
143
|
+ score = 0.45 * jac + 0.15 * industry_match + 0.20 * gsim + 0.10 * ver + 0.10 * rec
|
|
|
144
|
+ if score > 0:
|
|
|
145
|
+ ranked.append((score, p))
|
|
|
146
|
+
|
|
|
147
|
+ ranked.sort(key=lambda t: t[0], reverse=True)
|
|
|
148
|
+ top = ranked[:k]
|
|
|
149
|
+ return [RankedProfile(profile=p, score=s) for s, p in top]
|
|
|
150
|
+
|
|
|
151
|
+
|
|
|
152
|
+@dataclass
|
|
|
153
|
+class RankedOpportunity:
|
|
|
154
|
+ opportunity: Opportunity
|
|
|
155
|
+ score: float
|
|
|
156
|
+
|
|
|
157
|
+
|
|
|
158
|
+def rank_opportunities_for(user: User, k: int = 20) -> List[RankedOpportunity]:
|
|
|
159
|
+ try:
|
|
|
160
|
+ me_profile = Profile.objects.select_related("user").get(user=user)
|
|
|
161
|
+ except Profile.DoesNotExist:
|
|
|
162
|
+ me_profile = Profile(user=user) # empty defaults
|
|
|
163
|
+
|
|
|
164
|
+ query_tokens = tokenize_text((me_profile.bio or "") + " " + (me_profile.interests or "") + " " + (me_profile.industry or ""))
|
|
|
165
|
+
|
|
|
166
|
+ ranked: List[Tuple[float, Opportunity]] = []
|
|
|
167
|
+ for o in Opportunity.objects.all().iterator():
|
|
|
168
|
+ doc_tokens = tokenize_text(o.title + " " + (o.description or ""))
|
|
|
169
|
+ sim = cosine(query_tokens, doc_tokens)
|
|
|
170
|
+ freshness = time_decay(o.created_at, half_life_days=10.0)
|
|
|
171
|
+ score = 0.6 * sim + 0.4 * freshness
|
|
|
172
|
+ if score > 0:
|
|
|
173
|
+ ranked.append((score, o))
|
|
|
174
|
+
|
|
|
175
|
+ ranked.sort(key=lambda t: t[0], reverse=True)
|
|
|
176
|
+ top = ranked[:k]
|
|
|
177
|
+ return [RankedOpportunity(opportunity=o, score=s) for s, o in top]
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+from django.db.models.signals import post_save
|
|
|
2
|
+from django.contrib.auth.models import User
|
|
|
3
|
+from django.dispatch import receiver
|
|
|
4
|
+from .models import Profile
|
|
|
5
|
+
|
|
|
6
|
+@receiver(post_save, sender=User)
|
|
|
7
|
+def create_profile(sender, instance, created, **kwargs):
|
|
|
8
|
+ if created:
|
|
|
9
|
+ Profile.objects.create(user=instance)
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+from django.test import TestCase
|
|
|
2
|
+from django.contrib.auth.models import User
|
|
|
3
|
+from rest_framework.test import APIClient
|
|
|
4
|
+from django.utils import timezone
|
|
|
5
|
+
|
|
|
6
|
+from api.models import Profile, Opportunity, IntroductionRequest, BenefitEvent
|
|
|
7
|
+from api.services.matching import (
|
|
|
8
|
+ jaccard,
|
|
|
9
|
+ cosine,
|
|
|
10
|
+ tokenize_interests_text,
|
|
|
11
|
+ rank_users_for,
|
|
|
12
|
+ rank_opportunities_for,
|
|
|
13
|
+)
|
|
|
14
|
+from api.services.benefit import log_benefit_event, build_leaderboard_queryset
|
|
|
15
|
+
|
|
|
16
|
+
|
|
|
17
|
+class SimilarityUtilsTests(TestCase):
|
|
|
18
|
+ def test_tokenize_and_jaccard(self):
|
|
|
19
|
+ a = tokenize_interests_text("AI, Data Science")
|
|
|
20
|
+ b = tokenize_interests_text("data science, analytics")
|
|
|
21
|
+ self.assertGreater(jaccard(a, b), 0)
|
|
|
22
|
+ self.assertLessEqual(jaccard(a, b), 1)
|
|
|
23
|
+
|
|
|
24
|
+ def test_cosine(self):
|
|
|
25
|
+ self.assertAlmostEqual(cosine(["ai", "ai"], ["ai"]) > 0, True)
|
|
|
26
|
+ self.assertEqual(cosine(["a"], ["b"]), 0.0)
|
|
|
27
|
+
|
|
|
28
|
+
|
|
|
29
|
+class MatchingServiceTests(TestCase):
|
|
|
30
|
+ def setUp(self):
|
|
|
31
|
+ # Users
|
|
|
32
|
+ self.u1 = User.objects.create_user(username="alice", password="x")
|
|
|
33
|
+ self.u2 = User.objects.create_user(username="bob", password="x")
|
|
|
34
|
+ self.u3 = User.objects.create_user(username="carol", password="x")
|
|
|
35
|
+
|
|
|
36
|
+ # Profiles (user post_save signal creates a default Profile; update it)
|
|
|
37
|
+ self.p1 = Profile.objects.get(user=self.u1)
|
|
|
38
|
+ self.p1.bio = "Loves AI and data"
|
|
|
39
|
+ self.p1.interests = "AI, Data"
|
|
|
40
|
+ self.p1.is_verified = True
|
|
|
41
|
+ self.p1.save()
|
|
|
42
|
+
|
|
|
43
|
+ self.p2 = Profile.objects.get(user=self.u2)
|
|
|
44
|
+ self.p2.bio = "AI researcher"
|
|
|
45
|
+ self.p2.interests = "AI, ML"
|
|
|
46
|
+ self.p2.save()
|
|
|
47
|
+
|
|
|
48
|
+ self.p3 = Profile.objects.get(user=self.u3)
|
|
|
49
|
+ self.p3.bio = "Designer"
|
|
|
50
|
+ self.p3.interests = "UX, UI"
|
|
|
51
|
+ self.p3.save()
|
|
|
52
|
+
|
|
|
53
|
+ # Taggit tags (best-effort; fine if taggit not fully migrated yet)
|
|
|
54
|
+ try:
|
|
|
55
|
+ self.p1.tags.set(["ai", "data"]) # prefer tags in matching
|
|
|
56
|
+ self.p2.tags.set(["ai", "ml"])
|
|
|
57
|
+ self.p3.tags.set(["ux", "ui"])
|
|
|
58
|
+ except Exception:
|
|
|
59
|
+ pass
|
|
|
60
|
+ self.p1.industry = "Technology"
|
|
|
61
|
+ self.p2.industry = "Technology"
|
|
|
62
|
+ self.p3.industry = "Design"
|
|
|
63
|
+ self.p1.save(); self.p2.save(); self.p3.save()
|
|
|
64
|
+
|
|
|
65
|
+ # Opportunity
|
|
|
66
|
+ self.o1 = Opportunity.objects.create(title="Build Analytics Dashboard", description="Work with data and AI dashboards")
|
|
|
67
|
+
|
|
|
68
|
+ def test_rank_users_for_returns_scored_results(self):
|
|
|
69
|
+ ranked = rank_users_for(self.u1, k=5)
|
|
|
70
|
+ self.assertTrue(any(r.profile.user == self.u2 for r in ranked))
|
|
|
71
|
+ # Existing request excludes candidate
|
|
|
72
|
+ IntroductionRequest.objects.create(from_user=self.u1, to_user=self.u2, message="hi")
|
|
|
73
|
+ ranked2 = rank_users_for(self.u1, k=5)
|
|
|
74
|
+ self.assertFalse(any(r.profile.user == self.u2 for r in ranked2))
|
|
|
75
|
+
|
|
|
76
|
+ def test_rank_opportunities_for_uses_text_similarity(self):
|
|
|
77
|
+ ranked = rank_opportunities_for(self.u1, k=5)
|
|
|
78
|
+ self.assertTrue(any(r.opportunity == self.o1 and r.score > 0 for r in ranked))
|
|
|
79
|
+
|
|
|
80
|
+
|
|
|
81
|
+class MatchingEndpointsTests(TestCase):
|
|
|
82
|
+ def setUp(self):
|
|
|
83
|
+ self.client = APIClient()
|
|
|
84
|
+ self.user = User.objects.create_user(username="dave", password="x")
|
|
|
85
|
+ self.other = User.objects.create_user(username="erin", password="x")
|
|
|
86
|
+ self.pu = Profile.objects.get(user=self.user)
|
|
|
87
|
+ self.pu.bio = "Data person"
|
|
|
88
|
+ self.pu.interests = "Data, Analytics"
|
|
|
89
|
+ self.pu.save()
|
|
|
90
|
+
|
|
|
91
|
+ self.po = Profile.objects.get(user=self.other)
|
|
|
92
|
+ self.po.bio = "Analytics work"
|
|
|
93
|
+ self.po.interests = "Analytics, BI"
|
|
|
94
|
+ self.po.save()
|
|
|
95
|
+ self.pu.industry = "Technology"
|
|
|
96
|
+ self.po.industry = "Technology"
|
|
|
97
|
+ self.pu.save(); self.po.save()
|
|
|
98
|
+ try:
|
|
|
99
|
+ self.pu.tags.set(["data", "analytics"]) # optional
|
|
|
100
|
+ self.po.tags.set(["analytics", "bi"]) # optional
|
|
|
101
|
+ except Exception:
|
|
|
102
|
+ pass
|
|
|
103
|
+ Opportunity.objects.create(title="Analytics Role", description="Looking for data analytics help")
|
|
|
104
|
+
|
|
|
105
|
+ def test_profiles_match_requires_auth(self):
|
|
|
106
|
+ resp = self.client.get("/api/profiles/match/")
|
|
|
107
|
+ self.assertIn(resp.status_code, (401, 403))
|
|
|
108
|
+
|
|
|
109
|
+ def test_profiles_match_returns_results_for_authed_user(self):
|
|
|
110
|
+ self.client.force_authenticate(user=self.user)
|
|
|
111
|
+ resp = self.client.get("/api/profiles/match/?k=10")
|
|
|
112
|
+ self.assertEqual(resp.status_code, 200)
|
|
|
113
|
+ self.assertIsInstance(resp.json(), list)
|
|
|
114
|
+
|
|
|
115
|
+
|
|
|
116
|
+class BenefitAndLeaderboardTests(TestCase):
|
|
|
117
|
+ def setUp(self):
|
|
|
118
|
+ self.client = APIClient()
|
|
|
119
|
+ self.a = User.objects.create_user(username="alice", password="x")
|
|
|
120
|
+ self.b = User.objects.create_user(username="bob", password="x")
|
|
|
121
|
+ self.c = User.objects.create_user(username="carol", password="x")
|
|
|
122
|
+ # Default user for authenticated endpoints in this suite
|
|
|
123
|
+ self.user = self.a
|
|
|
124
|
+
|
|
|
125
|
+ def test_benefit_event_disallows_self(self):
|
|
|
126
|
+ with self.assertRaises(Exception):
|
|
|
127
|
+ BenefitEvent.objects.create(
|
|
|
128
|
+ benefactor=self.a,
|
|
|
129
|
+ beneficiary=self.a,
|
|
|
130
|
+ kind=BenefitEvent.KIND_REFERRAL,
|
|
|
131
|
+ points=10,
|
|
|
132
|
+ )
|
|
|
133
|
+
|
|
|
134
|
+ def test_leaderboard_all_time_and_weekly(self):
|
|
|
135
|
+ # a helps b twice, c once
|
|
|
136
|
+ log_benefit_event(benefactor=self.a, beneficiary=self.b, kind=BenefitEvent.KIND_REFERRAL)
|
|
|
137
|
+ log_benefit_event(benefactor=self.a, beneficiary=self.c, kind=BenefitEvent.KIND_ACCEPTED_ANSWER)
|
|
|
138
|
+ # c older event outside 7d window
|
|
|
139
|
+ old = BenefitEvent(
|
|
|
140
|
+ benefactor=self.c,
|
|
|
141
|
+ beneficiary=self.b,
|
|
|
142
|
+ kind=BenefitEvent.KIND_RECOMMENDATION,
|
|
|
143
|
+ points=5,
|
|
|
144
|
+ )
|
|
|
145
|
+ old.created_at = timezone.now() - timezone.timedelta(days=60)
|
|
|
146
|
+ old.save()
|
|
|
147
|
+
|
|
|
148
|
+ all_qs = build_leaderboard_queryset("all")
|
|
|
149
|
+ usernames = [u.username for u in all_qs]
|
|
|
150
|
+ self.assertIn("alice", usernames)
|
|
|
151
|
+ weekly_qs = build_leaderboard_queryset("weekly")
|
|
|
152
|
+ w_usernames = [u.username for u in weekly_qs]
|
|
|
153
|
+ self.assertIn("alice", w_usernames)
|
|
|
154
|
+ # carol's only event is too old for weekly
|
|
|
155
|
+ self.assertNotIn("carol", w_usernames)
|
|
|
156
|
+
|
|
|
157
|
+ def test_leaderboard_endpoints(self):
|
|
|
158
|
+ log_benefit_event(benefactor=self.a, beneficiary=self.b, kind=BenefitEvent.KIND_REFERRAL)
|
|
|
159
|
+ # Public leaderboard
|
|
|
160
|
+ resp = self.client.get("/api/leaderboard/?period=all&limit=10")
|
|
|
161
|
+ self.assertEqual(resp.status_code, 200)
|
|
|
162
|
+ self.assertIn("results", resp.json())
|
|
|
163
|
+ # Me endpoint requires auth
|
|
|
164
|
+ resp2 = self.client.get("/api/leaderboard/me/?period=all")
|
|
|
165
|
+ self.assertIn(resp2.status_code, (401, 403))
|
|
|
166
|
+ self.client.force_authenticate(user=self.a)
|
|
|
167
|
+ resp3 = self.client.get("/api/leaderboard/me/?period=all")
|
|
|
168
|
+ self.assertEqual(resp3.status_code, 200)
|
|
|
169
|
+ self.assertIsNotNone(resp3.json().get("result"))
|
|
|
170
|
+
|
|
|
171
|
+ def test_opportunities_recommend_returns_results_for_authed_user(self):
|
|
|
172
|
+ self.client.force_authenticate(user=self.user)
|
|
|
173
|
+ resp = self.client.get("/api/opportunities/recommend/?k=10")
|
|
|
174
|
+ self.assertEqual(resp.status_code, 200)
|
|
|
175
|
+ self.assertIsInstance(resp.json(), list)
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+from django.urls import path, include
|
|
|
2
|
+from rest_framework.routers import DefaultRouter
|
|
|
3
|
+from .views import ProfileViewSet, OpportunityViewSet, IntroductionRequestViewSet, LeaderboardView, LeaderboardMeView
|
|
|
4
|
+from recycle_core.views_api import (
|
|
|
5
|
+ OrganizationViewSet,
|
|
|
6
|
+ MaterialCategoryViewSet,
|
|
|
7
|
+ MaterialViewSet,
|
|
|
8
|
+ PriceListViewSet,
|
|
|
9
|
+ PriceListItemViewSet,
|
|
|
10
|
+ CustomerViewSet,
|
|
|
11
|
+ CustomerSiteViewSet,
|
|
|
12
|
+ ServiceAgreementViewSet,
|
|
|
13
|
+ PickupOrderViewSet,
|
|
|
14
|
+ PickupItemViewSet,
|
|
|
15
|
+ WeighTicketViewSet,
|
|
|
16
|
+ WeighLineViewSet,
|
|
|
17
|
+ InvoiceViewSet,
|
|
|
18
|
+ InvoiceLineViewSet,
|
|
|
19
|
+ PaymentViewSet,
|
|
|
20
|
+ PayoutViewSet,
|
|
|
21
|
+ ScrapListingViewSet,
|
|
|
22
|
+ ScrapListingItemViewSet,
|
|
|
23
|
+ ScrapBidViewSet,
|
|
|
24
|
+ ScrapAwardViewSet,
|
|
|
25
|
+ ScrapListingInviteViewSet,
|
|
|
26
|
+ DocumentViewSet,
|
|
|
27
|
+ AuditLogViewSet,
|
|
|
28
|
+)
|
|
|
29
|
+
|
|
|
30
|
+router = DefaultRouter()
|
|
|
31
|
+router.register(r'profiles', ProfileViewSet)
|
|
|
32
|
+router.register(r'opportunities', OpportunityViewSet)
|
|
|
33
|
+router.register(r'introduction-requests', IntroductionRequestViewSet)
|
|
|
34
|
+router.register(r'organizations', OrganizationViewSet)
|
|
|
35
|
+router.register(r'material-categories', MaterialCategoryViewSet)
|
|
|
36
|
+router.register(r'materials', MaterialViewSet)
|
|
|
37
|
+router.register(r'price-lists', PriceListViewSet)
|
|
|
38
|
+router.register(r'price-list-items', PriceListItemViewSet)
|
|
|
39
|
+router.register(r'customers', CustomerViewSet)
|
|
|
40
|
+router.register(r'customer-sites', CustomerSiteViewSet)
|
|
|
41
|
+router.register(r'service-agreements', ServiceAgreementViewSet)
|
|
|
42
|
+router.register(r'pickups', PickupOrderViewSet)
|
|
|
43
|
+router.register(r'pickup-items', PickupItemViewSet)
|
|
|
44
|
+router.register(r'weigh-tickets', WeighTicketViewSet)
|
|
|
45
|
+router.register(r'weigh-lines', WeighLineViewSet)
|
|
|
46
|
+router.register(r'invoices', InvoiceViewSet)
|
|
|
47
|
+router.register(r'invoice-lines', InvoiceLineViewSet)
|
|
|
48
|
+router.register(r'payments', PaymentViewSet)
|
|
|
49
|
+router.register(r'payouts', PayoutViewSet)
|
|
|
50
|
+router.register(r'scrap-listings', ScrapListingViewSet)
|
|
|
51
|
+router.register(r'scrap-listing-items', ScrapListingItemViewSet)
|
|
|
52
|
+router.register(r'scrap-bids', ScrapBidViewSet)
|
|
|
53
|
+router.register(r'scrap-awards', ScrapAwardViewSet)
|
|
|
54
|
+router.register(r'scrap-listing-invites', ScrapListingInviteViewSet)
|
|
|
55
|
+router.register(r'documents', DocumentViewSet)
|
|
|
56
|
+router.register(r'audit-logs', AuditLogViewSet)
|
|
|
57
|
+
|
|
|
58
|
+urlpatterns = [
|
|
|
59
|
+ path('', include(router.urls)),
|
|
|
60
|
+ path('leaderboard/', LeaderboardView.as_view({'get': 'list'})),
|
|
|
61
|
+ path('leaderboard/me/', LeaderboardMeView.as_view({'get': 'list'})),
|
|
|
62
|
+]
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+from rest_framework import viewsets
|
|
|
2
|
+from rest_framework.permissions import IsAuthenticated, AllowAny
|
|
|
3
|
+from rest_framework.decorators import action
|
|
|
4
|
+from rest_framework.response import Response
|
|
|
5
|
+from django_filters.rest_framework import DjangoFilterBackend
|
|
|
6
|
+from django.db.models import Q
|
|
|
7
|
+from .models import Profile, Opportunity, IntroductionRequest
|
|
|
8
|
+from .serializers import ProfileSerializer, OpportunitySerializer, IntroductionRequestSerializer, LeaderRowSerializer
|
|
|
9
|
+from .services.matching import rank_users_for, rank_opportunities_for
|
|
|
10
|
+from .services.benefit import build_leaderboard_queryset
|
|
|
11
|
+
|
|
|
12
|
+class ProfileViewSet(viewsets.ModelViewSet):
|
|
|
13
|
+ queryset = Profile.objects.all()
|
|
|
14
|
+ serializer_class = ProfileSerializer
|
|
|
15
|
+ permission_classes = [IsAuthenticated]
|
|
|
16
|
+ filter_backends = [DjangoFilterBackend]
|
|
|
17
|
+ filterset_fields = {
|
|
|
18
|
+ 'user__username': ['exact', 'icontains'],
|
|
|
19
|
+ 'is_verified': ['exact'],
|
|
|
20
|
+ 'industry': ['exact', 'icontains'],
|
|
|
21
|
+ 'interests': ['icontains'],
|
|
|
22
|
+ 'tags__name': ['exact', 'icontains'],
|
|
|
23
|
+ }
|
|
|
24
|
+
|
|
|
25
|
+ @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
|
|
26
|
+ def match(self, request):
|
|
|
27
|
+ """Return top user matches for the authenticated user."""
|
|
|
28
|
+ ranked = rank_users_for(request.user, k=int(request.query_params.get('k', 20)))
|
|
|
29
|
+ payload = [
|
|
|
30
|
+ {
|
|
|
31
|
+ 'score': round(item.score, 4),
|
|
|
32
|
+ 'profile': ProfileSerializer(item.profile, context={'request': request}).data,
|
|
|
33
|
+ }
|
|
|
34
|
+ for item in ranked
|
|
|
35
|
+ ]
|
|
|
36
|
+ return Response(payload)
|
|
|
37
|
+
|
|
|
38
|
+class OpportunityViewSet(viewsets.ModelViewSet):
|
|
|
39
|
+ queryset = Opportunity.objects.all()
|
|
|
40
|
+ serializer_class = OpportunitySerializer
|
|
|
41
|
+ permission_classes = [IsAuthenticated]
|
|
|
42
|
+ filter_backends = [DjangoFilterBackend]
|
|
|
43
|
+ filterset_fields = {
|
|
|
44
|
+ 'title': ['exact', 'icontains'],
|
|
|
45
|
+ 'created_at': ['gte', 'lte'],
|
|
|
46
|
+ }
|
|
|
47
|
+
|
|
|
48
|
+ @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
|
|
49
|
+ def recommend(self, request):
|
|
|
50
|
+ """Return top opportunity recommendations for the authenticated user."""
|
|
|
51
|
+ ranked = rank_opportunities_for(request.user, k=int(request.query_params.get('k', 20)))
|
|
|
52
|
+ payload = [
|
|
|
53
|
+ {
|
|
|
54
|
+ 'score': round(item.score, 4),
|
|
|
55
|
+ 'opportunity': OpportunitySerializer(item.opportunity, context={'request': request}).data,
|
|
|
56
|
+ }
|
|
|
57
|
+ for item in ranked
|
|
|
58
|
+ ]
|
|
|
59
|
+ return Response(payload)
|
|
|
60
|
+
|
|
|
61
|
+class IntroductionRequestViewSet(viewsets.ModelViewSet):
|
|
|
62
|
+ queryset = IntroductionRequest.objects.all()
|
|
|
63
|
+ serializer_class = IntroductionRequestSerializer
|
|
|
64
|
+ permission_classes = [IsAuthenticated]
|
|
|
65
|
+ filter_backends = [DjangoFilterBackend]
|
|
|
66
|
+ filterset_fields = {
|
|
|
67
|
+ 'from_user__username': ['exact', 'icontains'],
|
|
|
68
|
+ 'to_user__username': ['exact', 'icontains'],
|
|
|
69
|
+ 'is_accepted': ['exact'],
|
|
|
70
|
+ 'message': ['icontains'],
|
|
|
71
|
+ }
|
|
|
72
|
+
|
|
|
73
|
+ def get_queryset(self):
|
|
|
74
|
+ return IntroductionRequest.objects.filter(
|
|
|
75
|
+ Q(from_user=self.request.user) | Q(to_user=self.request.user)
|
|
|
76
|
+ )
|
|
|
77
|
+
|
|
|
78
|
+ def perform_create(self, serializer):
|
|
|
79
|
+ serializer.save(from_user=self.request.user)
|
|
|
80
|
+
|
|
|
81
|
+
|
|
|
82
|
+class LeaderboardView(viewsets.ViewSet):
|
|
|
83
|
+ permission_classes = [AllowAny]
|
|
|
84
|
+
|
|
|
85
|
+ def list(self, request):
|
|
|
86
|
+ period = request.query_params.get('period', 'weekly')
|
|
|
87
|
+ try:
|
|
|
88
|
+ limit = min(int(request.query_params.get('limit', 100)), 500)
|
|
|
89
|
+ except Exception:
|
|
|
90
|
+ limit = 100
|
|
|
91
|
+ try:
|
|
|
92
|
+ offset = int(request.query_params.get('offset', 0))
|
|
|
93
|
+ except Exception:
|
|
|
94
|
+ offset = 0
|
|
|
95
|
+ qs = build_leaderboard_queryset(period)
|
|
|
96
|
+ page = qs[offset:offset+limit]
|
|
|
97
|
+ data = LeaderRowSerializer(page, many=True).data
|
|
|
98
|
+ return Response({"period": period, "offset": offset, "limit": limit, "results": data})
|
|
|
99
|
+
|
|
|
100
|
+
|
|
|
101
|
+class LeaderboardMeView(viewsets.ViewSet):
|
|
|
102
|
+ permission_classes = [IsAuthenticated]
|
|
|
103
|
+
|
|
|
104
|
+ def list(self, request):
|
|
|
105
|
+ period = request.query_params.get('period', 'weekly')
|
|
|
106
|
+ qs = build_leaderboard_queryset(period)
|
|
|
107
|
+ me = qs.filter(id=request.user.id).first()
|
|
|
108
|
+ if not me:
|
|
|
109
|
+ return Response({
|
|
|
110
|
+ "period": period,
|
|
|
111
|
+ "result": None,
|
|
|
112
|
+ "detail": "No rank for current user in this period.",
|
|
|
113
|
+ })
|
|
|
114
|
+ return Response({"period": period, "result": LeaderRowSerializer(me).data})
|
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+"""CMS app for content models like Post and PostCategory."""
|
|
|
2
|
+
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+from django.contrib import admin
|
|
|
2
|
+from mptt.admin import MPTTModelAdmin
|
|
|
3
|
+from .models import Post, PostCategory
|
|
|
4
|
+
|
|
|
5
|
+
|
|
|
6
|
+@admin.register(PostCategory)
|
|
|
7
|
+class PostCategoryAdmin(MPTTModelAdmin):
|
|
|
8
|
+ list_display = ("name", "slug", "created_at")
|
|
|
9
|
+ search_fields = ("name", "slug")
|
|
|
10
|
+ prepopulated_fields = {"slug": ("name",)}
|
|
|
11
|
+
|
|
|
12
|
+
|
|
|
13
|
+@admin.register(Post)
|
|
|
14
|
+class PostAdmin(admin.ModelAdmin):
|
|
|
15
|
+ list_display = ("title", "category", "status", "published_at", "created_at")
|
|
|
16
|
+ list_filter = ("status", "category")
|
|
|
17
|
+ search_fields = ("title", "slug", "content")
|
|
|
18
|
+ prepopulated_fields = {"slug": ("title",)}
|
|
|
19
|
+ autocomplete_fields = ("category", "author")
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+from django.apps import AppConfig
|
|
|
2
|
+
|
|
|
3
|
+
|
|
|
4
|
+class CmsConfig(AppConfig):
|
|
|
5
|
+ default_auto_field = "django.db.models.BigAutoField"
|
|
|
6
|
+ name = "cms"
|
|
|
7
|
+ verbose_name = "CMS"
|
|
|
8
|
+
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+import django_filters as filters
|
|
|
4
|
+from crispy_forms.helper import FormHelper
|
|
|
5
|
+from crispy_forms.layout import Layout, Field
|
|
|
6
|
+
|
|
|
7
|
+from .models import Post, PostCategory
|
|
|
8
|
+
|
|
|
9
|
+
|
|
|
10
|
+class PostFilter(filters.FilterSet):
|
|
|
11
|
+ title = filters.CharFilter(field_name="title", lookup_expr="icontains", label="Title")
|
|
|
12
|
+ category = filters.ModelChoiceFilter(
|
|
|
13
|
+ field_name="category",
|
|
|
14
|
+ queryset=PostCategory.objects.all(),
|
|
|
15
|
+ label="Category",
|
|
|
16
|
+ )
|
|
|
17
|
+ status = filters.ChoiceFilter(field_name="status", choices=Post.STATUS_CHOICES, label="Status")
|
|
|
18
|
+ published_at = filters.DateTimeFromToRangeFilter(
|
|
|
19
|
+ field_name="published_at",
|
|
|
20
|
+ label="Published between",
|
|
|
21
|
+ widget=filters.widgets.RangeWidget(
|
|
|
22
|
+ attrs={
|
|
|
23
|
+ "type": "datetime-local",
|
|
|
24
|
+ "style": "color-scheme: light;",
|
|
|
25
|
+ "class": "border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
|
|
26
|
+ }
|
|
|
27
|
+ ),
|
|
|
28
|
+ )
|
|
|
29
|
+
|
|
|
30
|
+ class Meta:
|
|
|
31
|
+ model = Post
|
|
|
32
|
+ fields = ["title", "category", "status", "published_at"]
|
|
|
33
|
+
|
|
|
34
|
+ def __init__(self, *args, **kwargs):
|
|
|
35
|
+ super().__init__(*args, **kwargs)
|
|
|
36
|
+ helper = FormHelper()
|
|
|
37
|
+ helper.form_tag = False
|
|
|
38
|
+ helper.layout = Layout(
|
|
|
39
|
+ Field("title"),
|
|
|
40
|
+ Field("category"),
|
|
|
41
|
+ Field("status"),
|
|
|
42
|
+ Field("published_at"),
|
|
|
43
|
+ )
|
|
|
44
|
+ self.form.helper = helper
|
|
|
45
|
+
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+from django import forms
|
|
|
2
|
+from django.contrib.auth import get_user_model
|
|
|
3
|
+from crispy_forms.helper import FormHelper
|
|
|
4
|
+from crispy_forms.layout import Layout, Field
|
|
|
5
|
+from markdownfield.widgets import MDEWidget
|
|
|
6
|
+from .widgets import ImagePreviewWidget
|
|
|
7
|
+
|
|
|
8
|
+from .models import Post, PostCategory
|
|
|
9
|
+from mptt.forms import TreeNodeChoiceField
|
|
|
10
|
+
|
|
|
11
|
+
|
|
|
12
|
+User = get_user_model()
|
|
|
13
|
+
|
|
|
14
|
+
|
|
|
15
|
+class PostCategoryForm(forms.ModelForm):
|
|
|
16
|
+ class Meta:
|
|
|
17
|
+ model = PostCategory
|
|
|
18
|
+ fields = ["name", "slug", "parent", "description"]
|
|
|
19
|
+
|
|
|
20
|
+ def __init__(self, *args, **kwargs):
|
|
|
21
|
+ super().__init__(*args, **kwargs)
|
|
|
22
|
+ self.helper = FormHelper()
|
|
|
23
|
+ self.helper.form_tag = False
|
|
|
24
|
+ self.helper.layout = Layout(
|
|
|
25
|
+ Field("name"),
|
|
|
26
|
+ Field("slug"),
|
|
|
27
|
+ Field("parent"),
|
|
|
28
|
+ Field("description"),
|
|
|
29
|
+ )
|
|
|
30
|
+
|
|
|
31
|
+ def __init__(self, *args, **kwargs):
|
|
|
32
|
+ super().__init__(*args, **kwargs)
|
|
|
33
|
+ # Use TreeNodeChoiceField for hierarchical parent selection
|
|
|
34
|
+ self.fields["parent"] = TreeNodeChoiceField(
|
|
|
35
|
+ queryset=PostCategory.objects.all(), required=False, level_indicator="— "
|
|
|
36
|
+ )
|
|
|
37
|
+
|
|
|
38
|
+
|
|
|
39
|
+class PostForm(forms.ModelForm):
|
|
|
40
|
+ tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
|
|
|
41
|
+
|
|
|
42
|
+ class Meta:
|
|
|
43
|
+ model = Post
|
|
|
44
|
+ fields = [
|
|
|
45
|
+ "title",
|
|
|
46
|
+ "slug",
|
|
|
47
|
+ "category",
|
|
|
48
|
+ "author",
|
|
|
49
|
+ "excerpt",
|
|
|
50
|
+ "feature_image",
|
|
|
51
|
+ "content",
|
|
|
52
|
+ "status",
|
|
|
53
|
+ "published_at",
|
|
|
54
|
+ ]
|
|
|
55
|
+ widgets = {
|
|
|
56
|
+ "published_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
|
57
|
+ "content": MDEWidget(attrs={"rows": 20}),
|
|
|
58
|
+ "feature_image": ImagePreviewWidget(),
|
|
|
59
|
+ }
|
|
|
60
|
+
|
|
|
61
|
+ def __init__(self, *args, **kwargs):
|
|
|
62
|
+ super().__init__(*args, **kwargs)
|
|
|
63
|
+ self.helper = FormHelper()
|
|
|
64
|
+ self.helper.form_tag = False
|
|
|
65
|
+ self.helper.layout = Layout(
|
|
|
66
|
+ Field("title"),
|
|
|
67
|
+ Field("slug"),
|
|
|
68
|
+ Field("category"),
|
|
|
69
|
+ Field("author"),
|
|
|
70
|
+ Field("excerpt"),
|
|
|
71
|
+ Field("feature_image"),
|
|
|
72
|
+ Field("content"),
|
|
|
73
|
+ Field("status"),
|
|
|
74
|
+ Field("published_at"),
|
|
|
75
|
+ Field("tags"),
|
|
|
76
|
+ )
|
|
|
77
|
+ # Initialize tags from instance
|
|
|
78
|
+ if self.instance and getattr(self.instance, "pk", None):
|
|
|
79
|
+ try:
|
|
|
80
|
+ names = list(self.instance.tags.names())
|
|
|
81
|
+ self.fields["tags"].initial = ", ".join(names)
|
|
|
82
|
+ except Exception:
|
|
|
83
|
+ self.fields["tags"].initial = ""
|
|
|
84
|
+
|
|
|
85
|
+ def save(self, commit=True):
|
|
|
86
|
+ obj = super().save(commit)
|
|
|
87
|
+ tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
|
|
|
88
|
+ try:
|
|
|
89
|
+ if commit:
|
|
|
90
|
+ obj.tags.set(tags)
|
|
|
91
|
+ except Exception:
|
|
|
92
|
+ pass
|
|
|
93
|
+ return obj
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+# Package for CMS management commands
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+# Commands package
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from django.core.management.base import BaseCommand
|
|
|
4
|
+
|
|
|
5
|
+import os
|
|
|
6
|
+from pathlib import Path
|
|
|
7
|
+from django.conf import settings
|
|
|
8
|
+from django.core.files import File
|
|
|
9
|
+
|
|
|
10
|
+from cms.models import create_demo_data, PostCategory, Post
|
|
|
11
|
+from orgs.models import Organization
|
|
|
12
|
+
|
|
|
13
|
+
|
|
|
14
|
+class Command(BaseCommand):
|
|
|
15
|
+ help = "Seed demo categories and posts for the CMS app (optionally per organization)"
|
|
|
16
|
+
|
|
|
17
|
+ def handle(self, *args, **options):
|
|
|
18
|
+ org_ident = options.get("org")
|
|
|
19
|
+ org = None
|
|
|
20
|
+ if org_ident:
|
|
|
21
|
+ if org_ident.isdigit():
|
|
|
22
|
+ org = Organization.objects.filter(pk=int(org_ident)).first()
|
|
|
23
|
+ if org is None:
|
|
|
24
|
+ org = Organization.objects.filter(code=org_ident).first() or Organization.objects.filter(name=org_ident).first()
|
|
|
25
|
+ if org is None:
|
|
|
26
|
+ raise SystemExit(self.style.ERROR(f"Organization not found: {org_ident}"))
|
|
|
27
|
+ # Ensure root category per org if org is provided
|
|
|
28
|
+ if org is not None:
|
|
|
29
|
+ root_name = f"{org.code}_root"
|
|
|
30
|
+ root_slug = f"{org.code}-root"
|
|
|
31
|
+ root, _ = PostCategory.objects.get_or_create(
|
|
|
32
|
+ organization=org,
|
|
|
33
|
+ slug=root_slug,
|
|
|
34
|
+ defaults={
|
|
|
35
|
+ "name": root_name,
|
|
|
36
|
+ "parent": None,
|
|
|
37
|
+ "description": f"Root category for {org.code}",
|
|
|
38
|
+ },
|
|
|
39
|
+ )
|
|
|
40
|
+
|
|
|
41
|
+ result = create_demo_data(org=org)
|
|
|
42
|
+
|
|
|
43
|
+ # Move all top-level categories under the root (excluding the root itself)
|
|
|
44
|
+ if org is not None:
|
|
|
45
|
+ # Re-fetch root to be safe
|
|
|
46
|
+ root = PostCategory.objects.get(organization=org, slug=f"{org.code}-root")
|
|
|
47
|
+ top_levels = PostCategory.objects.filter(organization=org, parent__isnull=True).exclude(pk=root.pk)
|
|
|
48
|
+ for cat in top_levels:
|
|
|
49
|
+ cat.parent = root
|
|
|
50
|
+ cat.save()
|
|
|
51
|
+
|
|
|
52
|
+ # Attach demo feature images from static/demo/ if available
|
|
|
53
|
+ try:
|
|
|
54
|
+ demo_dir = Path(settings.BASE_DIR) / "static" / "demo"
|
|
|
55
|
+ if demo_dir.exists() and demo_dir.is_dir():
|
|
|
56
|
+ image_files = [p for p in sorted(demo_dir.iterdir()) if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}]
|
|
|
57
|
+ else:
|
|
|
58
|
+ image_files = []
|
|
|
59
|
+ except Exception:
|
|
|
60
|
+ image_files = []
|
|
|
61
|
+
|
|
|
62
|
+ if image_files:
|
|
|
63
|
+ posts_qs = Post.objects.all().order_by("id")
|
|
|
64
|
+ if org is not None:
|
|
|
65
|
+ posts_qs = posts_qs.filter(organization=org)
|
|
|
66
|
+ idx = 0
|
|
|
67
|
+ for post in posts_qs:
|
|
|
68
|
+ if not getattr(post, "feature_image", None) or not post.feature_image:
|
|
|
69
|
+ img_path = image_files[idx % len(image_files)]
|
|
|
70
|
+ try:
|
|
|
71
|
+ with open(img_path, "rb") as fh:
|
|
|
72
|
+ post.feature_image.save(img_path.name, File(fh), save=True)
|
|
|
73
|
+ except Exception:
|
|
|
74
|
+ # Skip on any IO error and continue with next
|
|
|
75
|
+ pass
|
|
|
76
|
+ idx += 1
|
|
|
77
|
+ suffix = f" for org {org.code}" if org else ""
|
|
|
78
|
+ self.stdout.write(self.style.SUCCESS(
|
|
|
79
|
+ f"CMS demo data seeded{suffix}: categories={result.get('categories', 0)}, posts={result.get('posts', 0)}"
|
|
|
80
|
+ ))
|
|
|
81
|
+
|
|
|
82
|
+ def add_arguments(self, parser):
|
|
|
83
|
+ parser.add_argument("--org", help="Organization code, id, or name to seed CMS data for", default=None)
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+# Generated by Django 4.2.24 on 2025-09-21 09:39
|
|
|
2
|
+
|
|
|
3
|
+from django.conf import settings
|
|
|
4
|
+from django.db import migrations, models
|
|
|
5
|
+import django.db.models.deletion
|
|
|
6
|
+import markdownfield.models
|
|
|
7
|
+import mptt.fields
|
|
|
8
|
+import taggit.managers
|
|
|
9
|
+
|
|
|
10
|
+
|
|
|
11
|
+class Migration(migrations.Migration):
|
|
|
12
|
+
|
|
|
13
|
+ initial = True
|
|
|
14
|
+
|
|
|
15
|
+ dependencies = [
|
|
|
16
|
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
|
17
|
+ ('orgs', '__first__'),
|
|
|
18
|
+ ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
|
|
19
|
+ ]
|
|
|
20
|
+
|
|
|
21
|
+ operations = [
|
|
|
22
|
+ migrations.CreateModel(
|
|
|
23
|
+ name='PostCategory',
|
|
|
24
|
+ fields=[
|
|
|
25
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
26
|
+ ('name', models.CharField(max_length=120)),
|
|
|
27
|
+ ('slug', models.SlugField(blank=True, max_length=140)),
|
|
|
28
|
+ ('description', models.TextField(blank=True)),
|
|
|
29
|
+ ('created_at', models.DateTimeField(auto_now_add=True)),
|
|
|
30
|
+ ('updated_at', models.DateTimeField(auto_now=True)),
|
|
|
31
|
+ ('lft', models.PositiveIntegerField(editable=False)),
|
|
|
32
|
+ ('rght', models.PositiveIntegerField(editable=False)),
|
|
|
33
|
+ ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
|
|
|
34
|
+ ('level', models.PositiveIntegerField(editable=False)),
|
|
|
35
|
+ ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cms_categories', to='orgs.organization')),
|
|
|
36
|
+ ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='cms.postcategory')),
|
|
|
37
|
+ ],
|
|
|
38
|
+ options={
|
|
|
39
|
+ 'ordering': ('tree_id', 'lft', 'name'),
|
|
|
40
|
+ },
|
|
|
41
|
+ ),
|
|
|
42
|
+ migrations.CreateModel(
|
|
|
43
|
+ name='Post',
|
|
|
44
|
+ fields=[
|
|
|
45
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
46
|
+ ('title', models.CharField(max_length=200)),
|
|
|
47
|
+ ('slug', models.SlugField(blank=True, max_length=220)),
|
|
|
48
|
+ ('excerpt', models.TextField(blank=True)),
|
|
|
49
|
+ ('feature_image', models.ImageField(blank=True, null=True, upload_to='posts/feature/%Y/%m/%d/')),
|
|
|
50
|
+ ('content', markdownfield.models.MarkdownField(rendered_field='content_html')),
|
|
|
51
|
+ ('content_html', markdownfield.models.RenderedMarkdownField(null=True)),
|
|
|
52
|
+ ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=12)),
|
|
|
53
|
+ ('published_at', models.DateTimeField(blank=True, null=True)),
|
|
|
54
|
+ ('created_at', models.DateTimeField(auto_now_add=True)),
|
|
|
55
|
+ ('updated_at', models.DateTimeField(auto_now=True)),
|
|
|
56
|
+ ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to=settings.AUTH_USER_MODEL)),
|
|
|
57
|
+ ('category', mptt.fields.TreeForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='posts', to='cms.postcategory')),
|
|
|
58
|
+ ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cms_posts', to='orgs.organization')),
|
|
|
59
|
+ ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
|
|
|
60
|
+ ],
|
|
|
61
|
+ options={
|
|
|
62
|
+ 'ordering': ('-published_at', '-created_at', '-id'),
|
|
|
63
|
+ },
|
|
|
64
|
+ ),
|
|
|
65
|
+ migrations.AddIndex(
|
|
|
66
|
+ model_name='postcategory',
|
|
|
67
|
+ index=models.Index(fields=['slug'], name='cms_postcat_slug_2a9a66_idx'),
|
|
|
68
|
+ ),
|
|
|
69
|
+ migrations.AddIndex(
|
|
|
70
|
+ model_name='postcategory',
|
|
|
71
|
+ index=models.Index(fields=['tree_id', 'lft'], name='cms_postcategory_tree_id_laed4'),
|
|
|
72
|
+ ),
|
|
|
73
|
+ migrations.AlterUniqueTogether(
|
|
|
74
|
+ name='postcategory',
|
|
|
75
|
+ unique_together={('organization', 'slug')},
|
|
|
76
|
+ ),
|
|
|
77
|
+ migrations.AddIndex(
|
|
|
78
|
+ model_name='post',
|
|
|
79
|
+ index=models.Index(fields=['status', 'published_at'], name='cms_post_status_c7074b_idx'),
|
|
|
80
|
+ ),
|
|
|
81
|
+ migrations.AddIndex(
|
|
|
82
|
+ model_name='post',
|
|
|
83
|
+ index=models.Index(fields=['slug'], name='cms_post_slug_90607d_idx'),
|
|
|
84
|
+ ),
|
|
|
85
|
+ migrations.AlterUniqueTogether(
|
|
|
86
|
+ name='post',
|
|
|
87
|
+ unique_together={('organization', 'slug')},
|
|
|
88
|
+ ),
|
|
|
89
|
+ ]
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from django.db import models
|
|
|
4
|
+from django.contrib.auth import get_user_model
|
|
|
5
|
+from django.utils.text import slugify
|
|
|
6
|
+from textwrap import dedent
|
|
|
7
|
+from django.utils import timezone
|
|
|
8
|
+from taggit.managers import TaggableManager
|
|
|
9
|
+from mptt.models import MPTTModel, TreeForeignKey
|
|
|
10
|
+from markdownfield.models import MarkdownField, RenderedMarkdownField
|
|
|
11
|
+from markdownfield.validators import VALIDATOR_STANDARD
|
|
|
12
|
+from orgs.models import Organization
|
|
|
13
|
+
|
|
|
14
|
+from typing import Optional
|
|
|
15
|
+User = get_user_model()
|
|
|
16
|
+
|
|
|
17
|
+
|
|
|
18
|
+class PostCategory(MPTTModel):
|
|
|
19
|
+ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_categories", null=True, blank=True)
|
|
|
20
|
+ name = models.CharField(max_length=120)
|
|
|
21
|
+ slug = models.SlugField(max_length=140, unique=False, blank=True)
|
|
|
22
|
+ description = models.TextField(blank=True)
|
|
|
23
|
+ parent = TreeForeignKey(
|
|
|
24
|
+ "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
|
|
|
25
|
+ )
|
|
|
26
|
+ created_at = models.DateTimeField(auto_now_add=True)
|
|
|
27
|
+ updated_at = models.DateTimeField(auto_now=True)
|
|
|
28
|
+
|
|
|
29
|
+ class MPTTMeta:
|
|
|
30
|
+ order_insertion_by = ["name"]
|
|
|
31
|
+
|
|
|
32
|
+ class Meta:
|
|
|
33
|
+ ordering = ("tree_id", "lft", "name")
|
|
|
34
|
+ indexes = [
|
|
|
35
|
+ models.Index(fields=["slug"]),
|
|
|
36
|
+ ]
|
|
|
37
|
+ unique_together = (("organization", "slug"),)
|
|
|
38
|
+
|
|
|
39
|
+ def save(self, *args, **kwargs):
|
|
|
40
|
+ if not self.slug:
|
|
|
41
|
+ self.slug = slugify(self.name)[:140]
|
|
|
42
|
+ super().save(*args, **kwargs)
|
|
|
43
|
+
|
|
|
44
|
+ def __str__(self) -> str: # pragma: no cover - trivial
|
|
|
45
|
+ return self.name
|
|
|
46
|
+
|
|
|
47
|
+
|
|
|
48
|
+class Post(models.Model):
|
|
|
49
|
+ STATUS_DRAFT = "draft"
|
|
|
50
|
+ STATUS_PUBLISHED = "published"
|
|
|
51
|
+ STATUS_CHOICES = (
|
|
|
52
|
+ (STATUS_DRAFT, "Draft"),
|
|
|
53
|
+ (STATUS_PUBLISHED, "Published"),
|
|
|
54
|
+ )
|
|
|
55
|
+
|
|
|
56
|
+ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_posts", null=True, blank=True)
|
|
|
57
|
+ title = models.CharField(max_length=200)
|
|
|
58
|
+ slug = models.SlugField(max_length=220, unique=False, blank=True)
|
|
|
59
|
+ category = TreeForeignKey(PostCategory, on_delete=models.PROTECT, related_name="posts")
|
|
|
60
|
+ author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts")
|
|
|
61
|
+ excerpt = models.TextField(blank=True)
|
|
|
62
|
+ feature_image = models.ImageField(upload_to="posts/feature/%Y/%m/%d/", null=True, blank=True)
|
|
|
63
|
+ # Markdown content source + auto-rendered HTML
|
|
|
64
|
+ content = MarkdownField(rendered_field="content_html", validator=VALIDATOR_STANDARD)
|
|
|
65
|
+ content_html = RenderedMarkdownField(blank=True, null=True)
|
|
|
66
|
+ status = models.CharField(max_length=12, choices=STATUS_CHOICES, default=STATUS_DRAFT)
|
|
|
67
|
+ published_at = models.DateTimeField(null=True, blank=True)
|
|
|
68
|
+ created_at = models.DateTimeField(auto_now_add=True)
|
|
|
69
|
+ updated_at = models.DateTimeField(auto_now=True)
|
|
|
70
|
+
|
|
|
71
|
+ # Optional tags, consistent with project usage
|
|
|
72
|
+ tags = TaggableManager(blank=True)
|
|
|
73
|
+
|
|
|
74
|
+ class Meta:
|
|
|
75
|
+ ordering = ("-published_at", "-created_at", "-id")
|
|
|
76
|
+ indexes = [
|
|
|
77
|
+ models.Index(fields=["status", "published_at"]),
|
|
|
78
|
+ models.Index(fields=["slug"]),
|
|
|
79
|
+ ]
|
|
|
80
|
+ unique_together = (("organization", "slug"),)
|
|
|
81
|
+
|
|
|
82
|
+ def save(self, *args, **kwargs):
|
|
|
83
|
+ if not self.slug and self.title:
|
|
|
84
|
+ base = slugify(self.title)[:220].strip('-')
|
|
|
85
|
+ slug = base or "post"
|
|
|
86
|
+ # Ensure uniqueness within organization by appending -2, -3, ... if needed
|
|
|
87
|
+ i = 2
|
|
|
88
|
+ qs = Post.objects
|
|
|
89
|
+ if self.organization_id:
|
|
|
90
|
+ qs = qs.filter(organization_id=self.organization_id)
|
|
|
91
|
+ while qs.filter(slug=slug).exclude(pk=self.pk).exists():
|
|
|
92
|
+ suffix = f"-{i}"
|
|
|
93
|
+ slug = (base[: 220 - len(suffix)] + suffix).strip('-') or f"post-{i}"
|
|
|
94
|
+ i += 1
|
|
|
95
|
+ self.slug = slug
|
|
|
96
|
+ if self.status == self.STATUS_PUBLISHED and not self.published_at:
|
|
|
97
|
+ self.published_at = timezone.now()
|
|
|
98
|
+ super().save(*args, **kwargs)
|
|
|
99
|
+
|
|
|
100
|
+ def __str__(self) -> str: # pragma: no cover - trivial
|
|
|
101
|
+ return self.title
|
|
|
102
|
+
|
|
|
103
|
+
|
|
|
104
|
+# --- Demo data helpers -------------------------------------------------------
|
|
|
105
|
+
|
|
|
106
|
+def create_demo_data(org: Optional[Organization] = None) -> dict:
|
|
|
107
|
+ """Create demo categories and posts for CMS.
|
|
|
108
|
+
|
|
|
109
|
+ Idempotent: safe to call multiple times. Returns a summary counts dict.
|
|
|
110
|
+ """
|
|
|
111
|
+ created = {"categories": 0, "posts": 0}
|
|
|
112
|
+
|
|
|
113
|
+ # Categories
|
|
|
114
|
+ cat_specs = [
|
|
|
115
|
+ {"name": "News", "description": "Company news and announcements"},
|
|
|
116
|
+ {"name": "Guides", "description": "How-to guides and tutorials"},
|
|
|
117
|
+ {"name": "Updates", "description": "Product updates and release notes"},
|
|
|
118
|
+ ]
|
|
|
119
|
+ cats: dict[str, PostCategory] = {}
|
|
|
120
|
+ for spec in cat_specs:
|
|
|
121
|
+ slug = slugify(spec["name"])[:140]
|
|
|
122
|
+ lookup = {"slug": slug}
|
|
|
123
|
+ if org is not None:
|
|
|
124
|
+ lookup["organization"] = org
|
|
|
125
|
+ obj, was_created = PostCategory.objects.get_or_create(
|
|
|
126
|
+ **lookup,
|
|
|
127
|
+ defaults={
|
|
|
128
|
+ "name": spec["name"],
|
|
|
129
|
+ "description": spec.get("description", ""),
|
|
|
130
|
+ "organization": org,
|
|
|
131
|
+ },
|
|
|
132
|
+ )
|
|
|
133
|
+ if was_created:
|
|
|
134
|
+ created["categories"] += 1
|
|
|
135
|
+ cats[obj.name] = obj
|
|
|
136
|
+
|
|
|
137
|
+ # Choose an author if available
|
|
|
138
|
+ try:
|
|
|
139
|
+ author = User.objects.filter(is_staff=True).order_by("id").first() or User.objects.order_by("id").first()
|
|
|
140
|
+ except Exception:
|
|
|
141
|
+ author = None
|
|
|
142
|
+
|
|
|
143
|
+ # Posts
|
|
|
144
|
+ posts_specs = [
|
|
|
145
|
+ {
|
|
|
146
|
+ "title": "Welcome to Ecoloop",
|
|
|
147
|
+ "category": cats.get("News"),
|
|
|
148
|
+ "excerpt": "Introducing our recycling operations platform.",
|
|
|
149
|
+ "content": "# Welcome to Ecoloop\n\nEcoloop helps factories and recyclers manage pickups, pricing, and a scrap marketplace.\nThis is a demo post seeded for your environment.",
|
|
|
150
|
+ "status": Post.STATUS_PUBLISHED,
|
|
|
151
|
+ "tags": ["ecoloop", "news"],
|
|
|
152
|
+ },
|
|
|
153
|
+ {
|
|
|
154
|
+ "title": "Getting Started Guide",
|
|
|
155
|
+ "category": cats.get("Guides"),
|
|
|
156
|
+ "excerpt": "How to create materials, customers, and pickups.",
|
|
|
157
|
+ "content": "# Getting Started\n\n1. Create your organization materials and price list.\n2. Add customers and sites.\n3. Create a pickup and record a weigh ticket.\n4. Generate invoices and payouts.",
|
|
|
158
|
+ "status": Post.STATUS_PUBLISHED,
|
|
|
159
|
+ "tags": ["guide", "howto"],
|
|
|
160
|
+ },
|
|
|
161
|
+ {
|
|
|
162
|
+ "title": "September Release Notes",
|
|
|
163
|
+ "category": cats.get("Updates"),
|
|
|
164
|
+ "excerpt": "Highlights from the latest release.",
|
|
|
165
|
+ "content": "# Release Notes\n\n- New scrap listings and bids\n- Auto award expired listings\n- Admin UI for pickups and invoices",
|
|
|
166
|
+ "status": Post.STATUS_DRAFT,
|
|
|
167
|
+ "tags": ["updates", "releases"],
|
|
|
168
|
+ },
|
|
|
169
|
+ ]
|
|
|
170
|
+
|
|
|
171
|
+ for spec in posts_specs:
|
|
|
172
|
+ base_slug = slugify(spec["title"])[:220]
|
|
|
173
|
+ lookup = {"slug": base_slug}
|
|
|
174
|
+ if org is not None:
|
|
|
175
|
+ lookup["organization"] = org
|
|
|
176
|
+ # Normalize multiline markdown content to avoid code-block wrapping
|
|
|
177
|
+ raw_content = spec.get("content", "")
|
|
|
178
|
+ content_md = dedent(raw_content).strip()
|
|
|
179
|
+ excerpt_text = spec.get("excerpt", "")
|
|
|
180
|
+ if isinstance(excerpt_text, str):
|
|
|
181
|
+ excerpt_text = dedent(excerpt_text).strip()
|
|
|
182
|
+ post, was_created = Post.objects.get_or_create(
|
|
|
183
|
+ **lookup,
|
|
|
184
|
+ defaults={
|
|
|
185
|
+ "title": spec["title"],
|
|
|
186
|
+ "category": spec.get("category") or next(iter(cats.values())),
|
|
|
187
|
+ "author": author,
|
|
|
188
|
+ "excerpt": excerpt_text,
|
|
|
189
|
+ "content": content_md,
|
|
|
190
|
+ "status": spec.get("status", Post.STATUS_DRAFT),
|
|
|
191
|
+ "organization": org,
|
|
|
192
|
+ },
|
|
|
193
|
+ )
|
|
|
194
|
+ if was_created:
|
|
|
195
|
+ created["posts"] += 1
|
|
|
196
|
+ try:
|
|
|
197
|
+ tags = spec.get("tags") or []
|
|
|
198
|
+ if tags:
|
|
|
199
|
+ post.tags.set(tags)
|
|
|
200
|
+ except Exception:
|
|
|
201
|
+ pass
|
|
|
202
|
+
|
|
|
203
|
+ return created
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+{% extends "admin_frontend/base.html" %}
|
|
|
2
|
+{% load crispy_forms_tags %}
|
|
|
3
|
+{% load mptt_tags %}
|
|
|
4
|
+
|
|
|
5
|
+{% block title %}CMS Categories{% endblock %}
|
|
|
6
|
+
|
|
|
7
|
+{% block content %}
|
|
|
8
|
+ <div class="bg-white rounded shadow p-4 mb-6">
|
|
|
9
|
+ <h1 class="text-xl font-semibold mb-4">Create Category</h1>
|
|
|
10
|
+ <form method="post">
|
|
|
11
|
+ {% csrf_token %}
|
|
|
12
|
+ {{ form|crispy }}
|
|
|
13
|
+ <div class="mt-3">
|
|
|
14
|
+ <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
|
|
|
15
|
+ </div>
|
|
|
16
|
+ </form>
|
|
|
17
|
+ </div>
|
|
|
18
|
+
|
|
|
19
|
+ <div class="bg-white rounded shadow p-4">
|
|
|
20
|
+ <div class="flex items-center justify-between mb-3">
|
|
|
21
|
+ <h2 class="text-lg font-semibold">Categories</h2>
|
|
|
22
|
+ <form method="get" class="flex items-center gap-2">
|
|
|
23
|
+ <input type="text" name="q" value="{{ query }}" placeholder="Search name..." class="border rounded px-3 py-2">
|
|
|
24
|
+ <button class="px-3 py-2 border rounded">Search</button>
|
|
|
25
|
+ </form>
|
|
|
26
|
+ </div>
|
|
|
27
|
+ <div>
|
|
|
28
|
+ {% if categories %}
|
|
|
29
|
+ <ul class="text-sm">
|
|
|
30
|
+ {% recursetree categories %}
|
|
|
31
|
+ <li class="py-1">
|
|
|
32
|
+ <div class="flex items-center gap-3">
|
|
|
33
|
+ <span class="inline-block border-l pl-3" style="margin-left: {{ node.level|add:'0' }}rem;">{{ node.name }}</span>
|
|
|
34
|
+ <span class="text-gray-500">({{ node.slug }})</span>
|
|
|
35
|
+ <a class="text-blue-600 hover:underline" href="{% url 'cms:category_edit' node.pk %}">Edit</a>
|
|
|
36
|
+ <form method="post" action="{% url 'cms:category_delete' node.pk %}" class="inline" onsubmit="return confirm('Delete this category?')">
|
|
|
37
|
+ {% csrf_token %}
|
|
|
38
|
+ <button class="text-red-600 hover:underline">Delete</button>
|
|
|
39
|
+ </form>
|
|
|
40
|
+ </div>
|
|
|
41
|
+ {% if not node.is_leaf_node %}
|
|
|
42
|
+ <ul class="ml-4">
|
|
|
43
|
+ {{ children }}
|
|
|
44
|
+ </ul>
|
|
|
45
|
+ {% endif %}
|
|
|
46
|
+ </li>
|
|
|
47
|
+ {% endrecursetree %}
|
|
|
48
|
+ </ul>
|
|
|
49
|
+ {% else %}
|
|
|
50
|
+ <div class="py-3 text-sm text-gray-600">No categories found.</div>
|
|
|
51
|
+ {% endif %}
|
|
|
52
|
+ </div>
|
|
|
53
|
+ </div>
|
|
|
54
|
+{% endblock %}
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+{% extends "admin_frontend/base.html" %}
|
|
|
2
|
+{% load crispy_forms_tags %}
|
|
|
3
|
+
|
|
|
4
|
+{% block title %}Edit Category{% endblock %}
|
|
|
5
|
+
|
|
|
6
|
+{% block content %}
|
|
|
7
|
+ <div class="bg-white rounded shadow p-4 mb-6">
|
|
|
8
|
+ <h1 class="text-xl font-semibold mb-4">Edit Category</h1>
|
|
|
9
|
+ <form method="post">
|
|
|
10
|
+ {% csrf_token %}
|
|
|
11
|
+ {{ form|crispy }}
|
|
|
12
|
+ <div class="mt-3 flex gap-3">
|
|
|
13
|
+ <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
|
|
14
|
+ <a class="px-4 py-2 border rounded" href="{{ cancel_url }}">Cancel</a>
|
|
|
15
|
+ </div>
|
|
|
16
|
+ </form>
|
|
|
17
|
+ </div>
|
|
|
18
|
+{% endblock %}
|
|
|
19
|
+
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+{% extends "admin_frontend/base.html" %}
|
|
|
2
|
+{% load crispy_forms_tags breadcrumbs %}
|
|
|
3
|
+
|
|
|
4
|
+{% block title %}Edit Post{% endblock %}
|
|
|
5
|
+
|
|
|
6
|
+{% block content %}
|
|
|
7
|
+{% render_breadcrumbs breadcrumbs %}
|
|
|
8
|
+{{ form.media }}
|
|
|
9
|
+ <div class="bg-white rounded shadow p-4 mb-6">
|
|
|
10
|
+ <h1 class="text-xl font-semibold mb-4">Edit Post</h1>
|
|
|
11
|
+ <form method="post" enctype="multipart/form-data">
|
|
|
12
|
+ {% csrf_token %}
|
|
|
13
|
+ {% for field in form %}
|
|
|
14
|
+ {{ field|as_crispy_field }}
|
|
|
15
|
+ {% endfor %}
|
|
|
16
|
+ <div class="mt-3 flex gap-3">
|
|
|
17
|
+ <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
|
|
18
|
+ <a class="px-4 py-2 border rounded" href="{{ cancel_url }}">Cancel</a>
|
|
|
19
|
+ </div>
|
|
|
20
|
+ </form>
|
|
|
21
|
+ </div>
|
|
|
22
|
+{% endblock %}
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+{% extends "admin_frontend/base.html" %}
|
|
|
2
|
+{% load crispy_forms_tags breadcrumbs %}
|
|
|
3
|
+
|
|
|
4
|
+{% block title %}CMS Posts{% endblock %}
|
|
|
5
|
+
|
|
|
6
|
+{% block content %}
|
|
|
7
|
+ {% render_breadcrumbs breadcrumbs %}
|
|
|
8
|
+ {{ form.media }}
|
|
|
9
|
+ <div class="bg-white rounded shadow p-4 mb-6">
|
|
|
10
|
+ <h1 class="text-xl font-semibold mb-4">Create Post</h1>
|
|
|
11
|
+ <form method="post" enctype="multipart/form-data" novalidate>
|
|
|
12
|
+ {% csrf_token %}
|
|
|
13
|
+ {{ form|crispy }}
|
|
|
14
|
+ <div class="mt-3">
|
|
|
15
|
+ <button type=submit class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
|
|
|
16
|
+ </div>
|
|
|
17
|
+ </form>
|
|
|
18
|
+ </div>
|
|
|
19
|
+
|
|
|
20
|
+ <div class="bg-white rounded shadow p-4">
|
|
|
21
|
+ <div class="flex items-center justify-between mb-3">
|
|
|
22
|
+ <h2 class="text-lg font-semibold">Posts</h2>
|
|
|
23
|
+ </div>
|
|
|
24
|
+ <form method="get" class="mb-4">
|
|
|
25
|
+ {% if filter %}
|
|
|
26
|
+ <div class="flex flex-wrap items-end gap-3">
|
|
|
27
|
+ {% for f in filter.form %}
|
|
|
28
|
+ <div class="min-w-[220px]">
|
|
|
29
|
+ {{ f|as_crispy_field }}
|
|
|
30
|
+ </div>
|
|
|
31
|
+ {% endfor %}
|
|
|
32
|
+ <div class="flex items-center gap-2">
|
|
|
33
|
+ <button class="px-3 py-2 border rounded">Apply</button>
|
|
|
34
|
+ <a href="?" class="px-3 py-2 border rounded">Reset</a>
|
|
|
35
|
+ </div>
|
|
|
36
|
+ </div>
|
|
|
37
|
+ {% endif %}
|
|
|
38
|
+ </form>
|
|
|
39
|
+ <div class="overflow-x-auto">
|
|
|
40
|
+ <table class="min-w-full text-sm">
|
|
|
41
|
+ <thead>
|
|
|
42
|
+ <tr class="text-left border-b">
|
|
|
43
|
+ <th class="py-2 pr-4">Title</th>
|
|
|
44
|
+ <th class="py-2 pr-4">Category</th>
|
|
|
45
|
+ <th class="py-2 pr-4">Status</th>
|
|
|
46
|
+ <th class="py-2 pr-4">Published</th>
|
|
|
47
|
+ <th class="py-2 pr-4">Actions</th>
|
|
|
48
|
+ </tr>
|
|
|
49
|
+ </thead>
|
|
|
50
|
+ <tbody>
|
|
|
51
|
+ {% for p in posts %}
|
|
|
52
|
+ <tr class="border-b">
|
|
|
53
|
+ <td class="py-2 pr-4">{{ p.title }}</td>
|
|
|
54
|
+ <td class="py-2 pr-4">{{ p.category }}</td>
|
|
|
55
|
+ <td class="py-2 pr-4">{{ p.get_status_display }}</td>
|
|
|
56
|
+ <td class="py-2 pr-4">{{ p.published_at|default:"–" }}</td>
|
|
|
57
|
+ <td class="py-2 pr-4">
|
|
|
58
|
+ <a class="text-blue-600 hover:underline" href="{% url 'cms:post_edit' p.pk %}">Edit</a>
|
|
|
59
|
+ <form method="post" action="{% url 'cms:post_delete' p.pk %}" class="inline" onsubmit="return confirm('Delete this post?')">
|
|
|
60
|
+ {% csrf_token %}
|
|
|
61
|
+ <button class="text-red-600 hover:underline ml-3">Delete</button>
|
|
|
62
|
+ </form>
|
|
|
63
|
+ </td>
|
|
|
64
|
+ </tr>
|
|
|
65
|
+ {% empty %}
|
|
|
66
|
+ <tr><td class="py-3" colspan="5">No posts found.</td></tr>
|
|
|
67
|
+ {% endfor %}
|
|
|
68
|
+ </tbody>
|
|
|
69
|
+ </table>
|
|
|
70
|
+ </div>
|
|
|
71
|
+
|
|
|
72
|
+ {% include "admin_frontend/_pagination.html" %}
|
|
|
73
|
+ </div>
|
|
|
74
|
+{% endblock %}
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+{# Custom image upload with preview, built on ClearableFileInput #}
|
|
|
2
|
+<div class="space-y-2">
|
|
|
3
|
+ <div>
|
|
|
4
|
+ {% if widget.is_initial and widget.value %}
|
|
|
5
|
+ {% if widget.value.url %}
|
|
|
6
|
+ <img id="preview_{{ widget.attrs.id }}" src="{{ widget.value.url }}" alt="Current image" class="max-h-56 rounded border" />
|
|
|
7
|
+ {% else %}
|
|
|
8
|
+ <img id="preview_{{ widget.attrs.id }}" alt="Current image" class="hidden max-h-56 rounded border" />
|
|
|
9
|
+ {% endif %}
|
|
|
10
|
+ {% else %}
|
|
|
11
|
+ <img id="preview_{{ widget.attrs.id }}" alt="Preview" class="hidden max-h-56 rounded border" />
|
|
|
12
|
+ {% endif %}
|
|
|
13
|
+ </div>
|
|
|
14
|
+
|
|
|
15
|
+ <div class="flex items-center gap-3">
|
|
|
16
|
+ <input type="file" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />
|
|
|
17
|
+ <span id="filename_{{ widget.attrs.id }}" class="text-sm text-gray-600 truncate"></span>
|
|
|
18
|
+ {% if not widget.required and widget.is_initial %}
|
|
|
19
|
+ <label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
|
|
20
|
+ <input type="checkbox" name="{{ widget.clear_checkbox_name }}" id="{{ widget.clear_checkbox_id }}" />
|
|
|
21
|
+ {{ widget.clear_checkbox_label }}
|
|
|
22
|
+ </label>
|
|
|
23
|
+ {% endif %}
|
|
|
24
|
+ </div>
|
|
|
25
|
+
|
|
|
26
|
+ {% if widget.is_initial and widget.value and widget.value.url %}
|
|
|
27
|
+ <div>
|
|
|
28
|
+ <a href="{{ widget.value.url }}" target="_blank" class="text-xs text-blue-600 hover:underline">Open current image</a>
|
|
|
29
|
+ </div>
|
|
|
30
|
+ {% endif %}
|
|
|
31
|
+
|
|
|
32
|
+ <script>
|
|
|
33
|
+ (function(){
|
|
|
34
|
+ const input = document.getElementById('{{ widget.attrs.id }}');
|
|
|
35
|
+ const preview = document.getElementById('preview_{{ widget.attrs.id }}');
|
|
|
36
|
+ const filenameEl = document.getElementById('filename_{{ widget.attrs.id }}');
|
|
|
37
|
+ if (!input || !preview) return;
|
|
|
38
|
+ input.addEventListener('change', function (e) {
|
|
|
39
|
+ const file = input.files && input.files[0];
|
|
|
40
|
+ if (!file) return;
|
|
|
41
|
+ if (filenameEl) filenameEl.textContent = file.name;
|
|
|
42
|
+ const url = URL.createObjectURL(file);
|
|
|
43
|
+ preview.src = url;
|
|
|
44
|
+ preview.classList.remove('hidden');
|
|
|
45
|
+ });
|
|
|
46
|
+ })();
|
|
|
47
|
+ </script>
|
|
|
48
|
+</div>
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from django import template
|
|
|
4
|
+
|
|
|
5
|
+register = template.Library()
|
|
|
6
|
+
|
|
|
7
|
+
|
|
|
8
|
+@register.inclusion_tag("admin_frontend/_breadcrumbs.html", takes_context=True)
|
|
|
9
|
+def render_breadcrumbs(context, breadcrumbs=None, aria_label: str = "Breadcrumb"):
|
|
|
10
|
+ """
|
|
|
11
|
+ Render breadcrumbs using a Tailwind-friendly partial.
|
|
|
12
|
+
|
|
|
13
|
+ Usage in templates:
|
|
|
14
|
+ {% load breadcrumbs %}
|
|
|
15
|
+ {% render_breadcrumbs breadcrumbs %}
|
|
|
16
|
+
|
|
|
17
|
+ "breadcrumbs" should be a list of (label, url) tuples.
|
|
|
18
|
+ Set url to None for the current (active) crumb.
|
|
|
19
|
+ """
|
|
|
20
|
+ request = context.get("request")
|
|
|
21
|
+ crumbs = breadcrumbs or context.get("breadcrumbs")
|
|
|
22
|
+ if crumbs is None and request is not None:
|
|
|
23
|
+ crumbs = getattr(request, "_breadcrumbs", [])
|
|
|
24
|
+ return {"breadcrumbs": crumbs or [], "aria_label": aria_label, "request": request}
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+from django.test import TestCase
|
|
|
2
|
+from django.contrib.auth import get_user_model
|
|
|
3
|
+
|
|
|
4
|
+from .models import Post, PostCategory
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+class CmsModelsTests(TestCase):
|
|
|
8
|
+ def setUp(self):
|
|
|
9
|
+ self.user = get_user_model().objects.create_user(username="writer", password="x")
|
|
|
10
|
+
|
|
|
11
|
+ def test_create_category_and_post(self):
|
|
|
12
|
+ cat = PostCategory.objects.create(name="News")
|
|
|
13
|
+ self.assertTrue(cat.slug)
|
|
|
14
|
+
|
|
|
15
|
+ post = Post.objects.create(
|
|
|
16
|
+ title="Hello World",
|
|
|
17
|
+ category=cat,
|
|
|
18
|
+ author=self.user,
|
|
|
19
|
+ content="Body",
|
|
|
20
|
+ status=Post.STATUS_DRAFT,
|
|
|
21
|
+ )
|
|
|
22
|
+ self.assertTrue(post.slug)
|
|
|
23
|
+ self.assertEqual(post.status, Post.STATUS_DRAFT)
|
|
|
24
|
+
|
|
|
25
|
+ def test_publish_sets_published_at(self):
|
|
|
26
|
+ cat = PostCategory.objects.create(name="Updates")
|
|
|
27
|
+ post = Post.objects.create(title="Release", category=cat, content="v1", status=Post.STATUS_PUBLISHED)
|
|
|
28
|
+ self.assertIsNotNone(post.published_at)
|
|
|
29
|
+
|
|
|
30
|
+ def test_markdown_renders_to_html(self):
|
|
|
31
|
+ cat = PostCategory.objects.create(name="Docs")
|
|
|
32
|
+ post = Post.objects.create(
|
|
|
33
|
+ title="Markdown Test",
|
|
|
34
|
+ category=cat,
|
|
|
35
|
+ content="# Hello\n\nThis is **bold**.",
|
|
|
36
|
+ status=Post.STATUS_DRAFT,
|
|
|
37
|
+ )
|
|
|
38
|
+ # Rendered HTML should be populated
|
|
|
39
|
+ self.assertIsNotNone(post.content_html)
|
|
|
40
|
+ self.assertIn("<h1>", post.content_html)
|
|
|
41
|
+ self.assertIn("<strong>", post.content_html)
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+from django.urls import path
|
|
|
2
|
+from . import views
|
|
|
3
|
+
|
|
|
4
|
+app_name = "cms"
|
|
|
5
|
+
|
|
|
6
|
+urlpatterns = [
|
|
|
7
|
+ path("posts/", views.posts_list, name="posts_list"),
|
|
|
8
|
+ path("posts/<int:pk>/edit/", views.post_edit, name="post_edit"),
|
|
|
9
|
+ path("posts/<int:pk>/delete/", views.post_delete, name="post_delete"),
|
|
|
10
|
+
|
|
|
11
|
+ path("categories/", views.categories_list, name="categories_list"),
|
|
|
12
|
+ path("categories/<int:pk>/edit/", views.category_edit, name="category_edit"),
|
|
|
13
|
+ path("categories/<int:pk>/delete/", views.category_delete, name="category_delete"),
|
|
|
14
|
+]
|
|
|
15
|
+
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+from django.shortcuts import render, redirect, get_object_or_404
|
|
|
2
|
+from django.contrib.admin.views.decorators import staff_member_required
|
|
|
3
|
+from django.views.decorators.http import require_POST
|
|
|
4
|
+from django.contrib import messages
|
|
|
5
|
+from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
|
|
6
|
+from django.urls import reverse, get_resolver, URLPattern, URLResolver, NoReverseMatch
|
|
|
7
|
+from functools import wraps
|
|
|
8
|
+from typing import Any, Dict, List, Optional, Tuple
|
|
|
9
|
+
|
|
|
10
|
+from admin_frontend.templatetags.public_urls import public_route
|
|
|
11
|
+from admin_frontend.nav import _nav_items
|
|
|
12
|
+
|
|
|
13
|
+from .models import Post, PostCategory
|
|
|
14
|
+from .forms import PostForm, PostCategoryForm
|
|
|
15
|
+from .filters import PostFilter
|
|
|
16
|
+
|
|
|
17
|
+
|
|
|
18
|
+# --- Lightweight breadcrumbs decorator -------------------------------------
|
|
|
19
|
+
|
|
|
20
|
+def _iter_patterns(resolver: URLResolver, ns_path: str = ""):
|
|
|
21
|
+ for entry in resolver.url_patterns:
|
|
|
22
|
+ if isinstance(entry, URLResolver):
|
|
|
23
|
+ new_ns = ns_path
|
|
|
24
|
+ if entry.namespace:
|
|
|
25
|
+ new_ns = f"{ns_path}:{entry.namespace}" if ns_path else entry.namespace
|
|
|
26
|
+ yield from _iter_patterns(entry, new_ns)
|
|
|
27
|
+ elif isinstance(entry, URLPattern):
|
|
|
28
|
+ full_name = entry.name
|
|
|
29
|
+ if full_name and ns_path:
|
|
|
30
|
+ full_name = f"{ns_path}:{full_name}"
|
|
|
31
|
+ yield entry, full_name
|
|
|
32
|
+
|
|
|
33
|
+
|
|
|
34
|
+def _find_pattern_by_breadcrumb_name(name: str) -> Tuple[Optional[URLPattern], Optional[str]]:
|
|
|
35
|
+ resolver = get_resolver()
|
|
|
36
|
+ for pattern, full_name in _iter_patterns(resolver):
|
|
|
37
|
+ if not full_name:
|
|
|
38
|
+ continue
|
|
|
39
|
+ view = pattern.callback
|
|
|
40
|
+ # unwrap to find metadata on inner functions
|
|
|
41
|
+ meta = None
|
|
|
42
|
+ cur = view
|
|
|
43
|
+ while cur is not None and meta is None:
|
|
|
44
|
+ meta = getattr(cur, "_breadcrumbs_meta", None)
|
|
|
45
|
+ cur = getattr(cur, "__wrapped__", None)
|
|
|
46
|
+ if meta and meta.get("name") == name:
|
|
|
47
|
+ return pattern, full_name
|
|
|
48
|
+ return None, None
|
|
|
49
|
+
|
|
|
50
|
+
|
|
|
51
|
+def _build_breadcrumb_chain(meta: Dict[str, Any]) -> List[Tuple[str, Optional[str]]]:
|
|
|
52
|
+ chain: List[Tuple[str, Optional[str]]] = []
|
|
|
53
|
+ # Walk parent chain (by name) outward
|
|
|
54
|
+ parent_name = meta.get("parent")
|
|
|
55
|
+ while parent_name:
|
|
|
56
|
+ pattern, full_name = _find_pattern_by_breadcrumb_name(parent_name)
|
|
|
57
|
+ if not pattern:
|
|
|
58
|
+ break
|
|
|
59
|
+ view = pattern.callback
|
|
|
60
|
+ vmeta = None
|
|
|
61
|
+ cur = view
|
|
|
62
|
+ while cur is not None and vmeta is None:
|
|
|
63
|
+ vmeta = getattr(cur, "_breadcrumbs_meta", None)
|
|
|
64
|
+ cur = getattr(cur, "__wrapped__", None)
|
|
|
65
|
+ vmeta = vmeta or {}
|
|
|
66
|
+ label = vmeta.get("label") or parent_name
|
|
|
67
|
+ # Try to reverse parent URL (skip if args required)
|
|
|
68
|
+ url: Optional[str] = None
|
|
|
69
|
+ if full_name:
|
|
|
70
|
+ try:
|
|
|
71
|
+ url = reverse(full_name)
|
|
|
72
|
+ except NoReverseMatch:
|
|
|
73
|
+ url = None
|
|
|
74
|
+ chain.insert(0, (label, url))
|
|
|
75
|
+ parent_name = vmeta.get("parent")
|
|
|
76
|
+ # Add current at the end (no URL)
|
|
|
77
|
+ chain.append((meta.get("label") or "", None))
|
|
|
78
|
+ return chain
|
|
|
79
|
+
|
|
|
80
|
+
|
|
|
81
|
+def breadcrumbs(*, label: str, name: Optional[str] = None, parent: Optional[str] = None):
|
|
|
82
|
+ """Decorator to attach breadcrumb metadata and expose a computed trail.
|
|
|
83
|
+
|
|
|
84
|
+ Usage:
|
|
|
85
|
+ @breadcrumbs(label="Posts", name="posts")
|
|
|
86
|
+ def posts_list(...): ...
|
|
|
87
|
+
|
|
|
88
|
+ @breadcrumbs(label="Post Edit", parent="posts")
|
|
|
89
|
+ def post_edit(...): ...
|
|
|
90
|
+ """
|
|
|
91
|
+
|
|
|
92
|
+ def _decorator(view_func):
|
|
|
93
|
+ meta = {"label": label, "name": name, "parent": parent}
|
|
|
94
|
+
|
|
|
95
|
+ @wraps(view_func)
|
|
|
96
|
+ def _wrapped(request, *args, **kwargs):
|
|
|
97
|
+ # Expose breadcrumbs via request for templates/tags to consume
|
|
|
98
|
+ request._breadcrumbs = _build_breadcrumb_chain(meta)
|
|
|
99
|
+ return view_func(request, *args, **kwargs)
|
|
|
100
|
+
|
|
|
101
|
+ # Make metadata discoverable on the resolved callback
|
|
|
102
|
+ setattr(_wrapped, "_breadcrumbs_meta", meta)
|
|
|
103
|
+ return _wrapped
|
|
|
104
|
+
|
|
|
105
|
+ return _decorator
|
|
|
106
|
+
|
|
|
107
|
+
|
|
|
108
|
+@staff_member_required
|
|
|
109
|
+@breadcrumbs(label="Posts", name="posts")
|
|
|
110
|
+@public_route(label="CMS Posts", order=70, icon="document-text")
|
|
|
111
|
+def posts_list(request):
|
|
|
112
|
+ if request.method == "POST":
|
|
|
113
|
+ form = PostForm(request.POST, request.FILES)
|
|
|
114
|
+ if form.is_valid():
|
|
|
115
|
+ obj = form.save(commit=False)
|
|
|
116
|
+ # Scope to current organization if available
|
|
|
117
|
+ org = getattr(request, "org", None)
|
|
|
118
|
+ if org is not None:
|
|
|
119
|
+ obj.organization = org
|
|
|
120
|
+ obj.save()
|
|
|
121
|
+ messages.success(request, "Post created.")
|
|
|
122
|
+ return redirect("cms:posts_list")
|
|
|
123
|
+ else:
|
|
|
124
|
+ messages.error(request, "Please correct the errors below.")
|
|
|
125
|
+ else:
|
|
|
126
|
+ form = PostForm()
|
|
|
127
|
+
|
|
|
128
|
+ # Restrict category choices to current organization
|
|
|
129
|
+ org = getattr(request, "org", None)
|
|
|
130
|
+ if org is not None:
|
|
|
131
|
+ try:
|
|
|
132
|
+ form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
|
|
|
133
|
+ except Exception:
|
|
|
134
|
+ pass
|
|
|
135
|
+
|
|
|
136
|
+ qs = Post.objects.select_related("category", "author").all().order_by("-published_at", "-created_at")
|
|
|
137
|
+ if org is not None:
|
|
|
138
|
+ qs = qs.filter(organization=org)
|
|
|
139
|
+ post_filter = PostFilter(request.GET, queryset=qs)
|
|
|
140
|
+ # Also scope the filter's category queryset
|
|
|
141
|
+ if org is not None:
|
|
|
142
|
+ try:
|
|
|
143
|
+ post_filter.form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
|
|
|
144
|
+ except Exception:
|
|
|
145
|
+ pass
|
|
|
146
|
+ paginator = Paginator(post_filter.qs, 10)
|
|
|
147
|
+ page = request.GET.get("page")
|
|
|
148
|
+ try:
|
|
|
149
|
+ page_obj = paginator.page(page)
|
|
|
150
|
+ except PageNotAnInteger:
|
|
|
151
|
+ page_obj = paginator.page(1)
|
|
|
152
|
+ except EmptyPage:
|
|
|
153
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
154
|
+
|
|
|
155
|
+ context = {
|
|
|
156
|
+ "nav": _nav_items(),
|
|
|
157
|
+ "posts": page_obj.object_list,
|
|
|
158
|
+ "page_obj": page_obj,
|
|
|
159
|
+ "form": form,
|
|
|
160
|
+ "filter": post_filter,
|
|
|
161
|
+ }
|
|
|
162
|
+ return render(request, "cms/posts_list.html", context)
|
|
|
163
|
+
|
|
|
164
|
+
|
|
|
165
|
+@staff_member_required
|
|
|
166
|
+@breadcrumbs(label="Post Edit", parent="posts")
|
|
|
167
|
+def post_edit(request, pk: int):
|
|
|
168
|
+ org = getattr(request, "org", None)
|
|
|
169
|
+ base_qs = Post.objects.all()
|
|
|
170
|
+ if org is not None:
|
|
|
171
|
+ base_qs = base_qs.filter(organization=org)
|
|
|
172
|
+ item = get_object_or_404(base_qs, pk=pk)
|
|
|
173
|
+ if request.method == "POST":
|
|
|
174
|
+ form = PostForm(request.POST, request.FILES, instance=item)
|
|
|
175
|
+ if form.is_valid():
|
|
|
176
|
+ obj = form.save(commit=False)
|
|
|
177
|
+ org = getattr(request, "org", None)
|
|
|
178
|
+ if org is not None:
|
|
|
179
|
+ obj.organization = org
|
|
|
180
|
+ obj.save()
|
|
|
181
|
+ messages.success(request, "Post updated.")
|
|
|
182
|
+ return redirect("cms:post_edit", pk=item.pk)
|
|
|
183
|
+ else:
|
|
|
184
|
+ form = PostForm(instance=item)
|
|
|
185
|
+
|
|
|
186
|
+ # Restrict category choices
|
|
|
187
|
+ org = getattr(request, "org", None)
|
|
|
188
|
+ if org is not None:
|
|
|
189
|
+ try:
|
|
|
190
|
+ form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
|
|
|
191
|
+ except Exception:
|
|
|
192
|
+ pass
|
|
|
193
|
+
|
|
|
194
|
+ context = {
|
|
|
195
|
+ "nav": _nav_items(),
|
|
|
196
|
+ "item": item,
|
|
|
197
|
+ "form": form,
|
|
|
198
|
+ "cancel_url": reverse("cms:posts_list"),
|
|
|
199
|
+ }
|
|
|
200
|
+ return render(request, "cms/post_form.html", context)
|
|
|
201
|
+
|
|
|
202
|
+
|
|
|
203
|
+@staff_member_required
|
|
|
204
|
+@require_POST
|
|
|
205
|
+def post_delete(request, pk: int):
|
|
|
206
|
+ org = getattr(request, "org", None)
|
|
|
207
|
+ base_qs = Post.objects.all()
|
|
|
208
|
+ if org is not None:
|
|
|
209
|
+ base_qs = base_qs.filter(organization=org)
|
|
|
210
|
+ item = get_object_or_404(base_qs, pk=pk)
|
|
|
211
|
+ item.delete()
|
|
|
212
|
+ messages.success(request, "Post deleted.")
|
|
|
213
|
+ return redirect("cms:posts_list")
|
|
|
214
|
+
|
|
|
215
|
+
|
|
|
216
|
+@staff_member_required
|
|
|
217
|
+@public_route(label="CMS Categories", order=75, icon="tag")
|
|
|
218
|
+def categories_list(request):
|
|
|
219
|
+ if request.method == "POST":
|
|
|
220
|
+ form = PostCategoryForm(request.POST)
|
|
|
221
|
+ if form.is_valid():
|
|
|
222
|
+ obj = form.save(commit=False)
|
|
|
223
|
+ org = getattr(request, "org", None)
|
|
|
224
|
+ if org is not None:
|
|
|
225
|
+ obj.organization = org
|
|
|
226
|
+ obj.save()
|
|
|
227
|
+ messages.success(request, "Category created.")
|
|
|
228
|
+ return redirect("cms:categories_list")
|
|
|
229
|
+ else:
|
|
|
230
|
+ messages.error(request, "Please correct the errors below.")
|
|
|
231
|
+ else:
|
|
|
232
|
+ form = PostCategoryForm()
|
|
|
233
|
+
|
|
|
234
|
+ q = (request.GET.get("q") or "").strip()
|
|
|
235
|
+ qs = PostCategory.objects.all().order_by("name")
|
|
|
236
|
+ org = getattr(request, "org", None)
|
|
|
237
|
+ if org is not None:
|
|
|
238
|
+ qs = qs.filter(organization=org)
|
|
|
239
|
+ if q:
|
|
|
240
|
+ qs = qs.filter(name__icontains=q)
|
|
|
241
|
+
|
|
|
242
|
+ paginator = Paginator(qs, 15)
|
|
|
243
|
+ page = request.GET.get("page")
|
|
|
244
|
+ try:
|
|
|
245
|
+ page_obj = paginator.page(page)
|
|
|
246
|
+ except PageNotAnInteger:
|
|
|
247
|
+ page_obj = paginator.page(1)
|
|
|
248
|
+ except EmptyPage:
|
|
|
249
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
250
|
+
|
|
|
251
|
+ context = {
|
|
|
252
|
+ "nav": _nav_items(),
|
|
|
253
|
+ "categories": qs,
|
|
|
254
|
+ "page_obj": page_obj,
|
|
|
255
|
+ "form": form,
|
|
|
256
|
+ "query": q,
|
|
|
257
|
+ }
|
|
|
258
|
+ return render(request, "cms/categories_list.html", context)
|
|
|
259
|
+
|
|
|
260
|
+
|
|
|
261
|
+@staff_member_required
|
|
|
262
|
+def category_edit(request, pk: int):
|
|
|
263
|
+ org = getattr(request, "org", None)
|
|
|
264
|
+ base_qs = PostCategory.objects.all()
|
|
|
265
|
+ if org is not None:
|
|
|
266
|
+ base_qs = base_qs.filter(organization=org)
|
|
|
267
|
+ item = get_object_or_404(base_qs, pk=pk)
|
|
|
268
|
+ if request.method == "POST":
|
|
|
269
|
+ form = PostCategoryForm(request.POST, instance=item)
|
|
|
270
|
+ if form.is_valid():
|
|
|
271
|
+ obj = form.save(commit=False)
|
|
|
272
|
+ org = getattr(request, "org", None)
|
|
|
273
|
+ if org is not None:
|
|
|
274
|
+ obj.organization = org
|
|
|
275
|
+ obj.save()
|
|
|
276
|
+ messages.success(request, "Category updated.")
|
|
|
277
|
+ return redirect("cms:category_edit", pk=item.pk)
|
|
|
278
|
+ else:
|
|
|
279
|
+ form = PostCategoryForm(instance=item)
|
|
|
280
|
+
|
|
|
281
|
+ # Restrict parent choices
|
|
|
282
|
+ org = getattr(request, "org", None)
|
|
|
283
|
+ if org is not None:
|
|
|
284
|
+ try:
|
|
|
285
|
+ form.fields["parent"].queryset = PostCategory.objects.filter(organization=org)
|
|
|
286
|
+ except Exception:
|
|
|
287
|
+ pass
|
|
|
288
|
+
|
|
|
289
|
+ context = {
|
|
|
290
|
+ "nav": _nav_items(),
|
|
|
291
|
+ "item": item,
|
|
|
292
|
+ "form": form,
|
|
|
293
|
+ "cancel_url": reverse("cms:categories_list"),
|
|
|
294
|
+ }
|
|
|
295
|
+ return render(request, "cms/category_form.html", context)
|
|
|
296
|
+
|
|
|
297
|
+
|
|
|
298
|
+@staff_member_required
|
|
|
299
|
+@require_POST
|
|
|
300
|
+def category_delete(request, pk: int):
|
|
|
301
|
+ org = getattr(request, "org", None)
|
|
|
302
|
+ base_qs = PostCategory.objects.all()
|
|
|
303
|
+ if org is not None:
|
|
|
304
|
+ base_qs = base_qs.filter(organization=org)
|
|
|
305
|
+ item = get_object_or_404(base_qs, pk=pk)
|
|
|
306
|
+ item.delete()
|
|
|
307
|
+ messages.success(request, "Category deleted.")
|
|
|
308
|
+ return redirect("cms:categories_list")
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from django.forms.widgets import ClearableFileInput
|
|
|
4
|
+
|
|
|
5
|
+
|
|
|
6
|
+class ImagePreviewWidget(ClearableFileInput):
|
|
|
7
|
+ template_name = "cms/widgets/image_preview_input.html"
|
|
|
8
|
+
|
|
|
9
|
+ def __init__(self, attrs=None):
|
|
|
10
|
+ attrs = {**({} if attrs is None else attrs), "accept": "image/*"}
|
|
|
11
|
+ super().__init__(attrs=attrs)
|
|
|
12
|
+
|
|
|
13
|
+ def get_context(self, name, value, attrs):
|
|
|
14
|
+ # Merge our button-like classes into any provided classes without duplicating the class attribute in template
|
|
|
15
|
+ base_classes = (
|
|
|
16
|
+ "file:mr-3 file:px-3 file:py-2 file:rounded file:border-0 "
|
|
|
17
|
+ "file:bg-blue-600 file:text-white hover:file:bg-blue-700 text-sm text-gray-700"
|
|
|
18
|
+ )
|
|
|
19
|
+ attrs = attrs or {}
|
|
|
20
|
+ existing = (attrs.get("class") or self.attrs.get("class") or "").strip()
|
|
|
21
|
+ merged = (existing + " " + base_classes).strip() if existing else base_classes
|
|
|
22
|
+ attrs["class"] = merged
|
|
|
23
|
+ return super().get_context(name, value, attrs)
|
|
|
24
|
+
|
|
|
25
|
+ class Media:
|
|
|
26
|
+ css = {"all": []}
|
|
|
27
|
+ js = []
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+version: '3.8'
|
|
|
2
|
+
|
|
|
3
|
+services:
|
|
|
4
|
+ web:
|
|
|
5
|
+ build: .
|
|
|
6
|
+ command: sh -c "python manage.py tailwind dev & python manage.py runserver 0.0.0.0:8000"
|
|
|
7
|
+ volumes:
|
|
|
8
|
+ - .:/app
|
|
|
9
|
+ ports:
|
|
|
10
|
+ - "8024:8000"
|
|
|
11
|
+ depends_on:
|
|
|
12
|
+ - db
|
|
|
13
|
+ env_file:
|
|
|
14
|
+ - .env
|
|
|
15
|
+ db:
|
|
|
16
|
+ image: postgres:13
|
|
|
17
|
+ volumes:
|
|
|
18
|
+ - postgres_data:/var/lib/postgresql/data/
|
|
|
19
|
+ env_file:
|
|
|
20
|
+ - .env
|
|
|
21
|
+
|
|
|
22
|
+volumes:
|
|
|
23
|
+ postgres_data:
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+"""
|
|
|
2
|
+ASGI config for lux_backend project.
|
|
|
3
|
+
|
|
|
4
|
+It exposes the ASGI callable as a module-level variable named ``application``.
|
|
|
5
|
+
|
|
|
6
|
+For more information on this file, see
|
|
|
7
|
+https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
|
|
8
|
+"""
|
|
|
9
|
+
|
|
|
10
|
+import os
|
|
|
11
|
+
|
|
|
12
|
+from django.core.asgi import get_asgi_application
|
|
|
13
|
+
|
|
|
14
|
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
|
|
|
15
|
+
|
|
|
16
|
+application = get_asgi_application()
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from typing import Any, Dict
|
|
|
4
|
+
|
|
|
5
|
+try:
|
|
|
6
|
+ from django.contrib.sites.models import Site
|
|
|
7
|
+except Exception: # pragma: no cover - sites may be disabled in some envs
|
|
|
8
|
+ Site = None # type: ignore
|
|
|
9
|
+
|
|
|
10
|
+
|
|
|
11
|
+def current_site(request) -> Dict[str, Any]:
|
|
|
12
|
+ """Inject the current Site object as `current_site` into templates.
|
|
|
13
|
+
|
|
|
14
|
+ Falls back to None if Sites is not available.
|
|
|
15
|
+ """
|
|
|
16
|
+ site = None
|
|
|
17
|
+ # Prefer host-based resolution so different domains work even when SITE_ID is set.
|
|
|
18
|
+ if Site is not None:
|
|
|
19
|
+ try:
|
|
|
20
|
+ host = request.get_host().split(":")[0].lower()
|
|
|
21
|
+ site = Site.objects.get(domain__iexact=host)
|
|
|
22
|
+ except Exception:
|
|
|
23
|
+ try:
|
|
|
24
|
+ # Fallback to the default configured Site
|
|
|
25
|
+ site = Site.objects.get_current()
|
|
|
26
|
+ except Exception:
|
|
|
27
|
+ site = None
|
|
|
28
|
+ return {"current_site": site}
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from django.utils.deprecation import MiddlewareMixin
|
|
|
4
|
+from django.contrib.auth.views import redirect_to_login
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+class WebAdminAuthRedirectMiddleware(MiddlewareMixin):
|
|
|
8
|
+ """Redirect unauthenticated /webadmin/* requests to /webadmin/login/.
|
|
|
9
|
+
|
|
|
10
|
+ This ensures that when a session expires (or user is anonymous), accessing
|
|
|
11
|
+ any webadmin page leads to the webadmin login screen, regardless of the
|
|
|
12
|
+ global LOGIN_URL setting.
|
|
|
13
|
+ """
|
|
|
14
|
+
|
|
|
15
|
+ def process_request(self, request):
|
|
|
16
|
+ path = request.path
|
|
|
17
|
+ if not path.startswith("/webadmin/"):
|
|
|
18
|
+ return None
|
|
|
19
|
+ if request.user.is_authenticated:
|
|
|
20
|
+ return None
|
|
|
21
|
+ # Allow the login and logout endpoints without redirect loop
|
|
|
22
|
+ if path.startswith("/webadmin/login/") or path.startswith("/webadmin/logout/"):
|
|
|
23
|
+ return None
|
|
|
24
|
+ return redirect_to_login(request.get_full_path(), login_url="/webadmin/login/")
|
|
|
25
|
+
|
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+"""
|
|
|
2
|
+Django settings for lux_backend project.
|
|
|
3
|
+
|
|
|
4
|
+Generated by 'django-admin startproject' using Django 4.2.23.
|
|
|
5
|
+
|
|
|
6
|
+For more information on this file, see
|
|
|
7
|
+https://docs.djangoproject.com/en/4.2/topics/settings/
|
|
|
8
|
+
|
|
|
9
|
+For the full list of settings and their values, see
|
|
|
10
|
+https://docs.djangoproject.com/en/4.2/ref/settings/
|
|
|
11
|
+"""
|
|
|
12
|
+
|
|
|
13
|
+from pathlib import Path
|
|
|
14
|
+import os
|
|
|
15
|
+from dotenv import load_dotenv
|
|
|
16
|
+
|
|
|
17
|
+load_dotenv()
|
|
|
18
|
+
|
|
|
19
|
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
|
20
|
+BASE_DIR = Path(__file__).resolve().parent.parent
|
|
|
21
|
+
|
|
|
22
|
+
|
|
|
23
|
+# Quick-start development settings - unsuitable for production
|
|
|
24
|
+# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
|
|
25
|
+
|
|
|
26
|
+# SECURITY WARNING: keep the secret key used in production secret!
|
|
|
27
|
+SECRET_KEY = os.getenv('SECRET_KEY')
|
|
|
28
|
+
|
|
|
29
|
+# SECURITY WARNING: don't run with debug turned on in production!
|
|
|
30
|
+DEBUG = os.getenv('DEBUG') == '1'
|
|
|
31
|
+
|
|
|
32
|
+ALLOWED_HOSTS = [h for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1,.lvh.me').split(',') if h]
|
|
|
33
|
+
|
|
|
34
|
+# CSRF trusted origins must include scheme and port when applicable
|
|
|
35
|
+_csrf_env = os.getenv('CSRF_TRUSTED_ORIGINS', '')
|
|
|
36
|
+if _csrf_env:
|
|
|
37
|
+ CSRF_TRUSTED_ORIGINS = [o for o in _csrf_env.split(',') if o]
|
|
|
38
|
+else:
|
|
|
39
|
+ CSRF_TRUSTED_ORIGINS = [
|
|
|
40
|
+ 'http://localhost:8000',
|
|
|
41
|
+ 'http://127.0.0.1:8000',
|
|
|
42
|
+ 'http://localhost:8024',
|
|
|
43
|
+ 'http://127.0.0.1:8024',
|
|
|
44
|
+ 'http://*.lvh.me:8000',
|
|
|
45
|
+ 'http://*.lvh.me:8024',
|
|
|
46
|
+ ]
|
|
|
47
|
+
|
|
|
48
|
+
|
|
|
49
|
+# Application definition
|
|
|
50
|
+
|
|
|
51
|
+INSTALLED_APPS = [
|
|
|
52
|
+ 'django.contrib.admin',
|
|
|
53
|
+ 'django.contrib.auth',
|
|
|
54
|
+ 'django.contrib.contenttypes',
|
|
|
55
|
+ 'django.contrib.sessions',
|
|
|
56
|
+ 'django.contrib.messages',
|
|
|
57
|
+ 'django.contrib.sites',
|
|
|
58
|
+ 'rest_framework',
|
|
|
59
|
+ 'djoser',
|
|
|
60
|
+ 'drf_spectacular',
|
|
|
61
|
+ 'django_filters',
|
|
|
62
|
+ 'taggit',
|
|
|
63
|
+ 'mptt',
|
|
|
64
|
+ 'tailwind',
|
|
|
65
|
+ 'crispy_forms',
|
|
|
66
|
+ 'crispy_tailwind',
|
|
|
67
|
+ 'markdownfield',
|
|
|
68
|
+ 'django.contrib.staticfiles',
|
|
|
69
|
+ 'allauth',
|
|
|
70
|
+ 'allauth.account',
|
|
|
71
|
+ 'allauth.socialaccount',
|
|
|
72
|
+ 'allauth.socialaccount.providers.google',
|
|
|
73
|
+ 'allauth.socialaccount.providers.facebook',
|
|
|
74
|
+ 'allauth.socialaccount.providers.line',
|
|
|
75
|
+ 'orgs',
|
|
|
76
|
+ 'public_frontend',
|
|
|
77
|
+ 'theme',
|
|
|
78
|
+ 'settings',
|
|
|
79
|
+ 'admin_frontend',
|
|
|
80
|
+ 'frontend',
|
|
|
81
|
+ 'api',
|
|
|
82
|
+ 'cms',
|
|
|
83
|
+ 'recycle_core'
|
|
|
84
|
+]
|
|
|
85
|
+if DEBUG:
|
|
|
86
|
+ # Add django_browser_reload only in DEBUG mode
|
|
|
87
|
+ INSTALLED_APPS += ['django_browser_reload']
|
|
|
88
|
+
|
|
|
89
|
+
|
|
|
90
|
+TAILWIND_APP_NAME = 'theme'
|
|
|
91
|
+
|
|
|
92
|
+# django-crispy-forms (Tailwind)
|
|
|
93
|
+CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
|
|
|
94
|
+CRISPY_TEMPLATE_PACK = "tailwind"
|
|
|
95
|
+
|
|
|
96
|
+STATICFILES_DIRS = [
|
|
|
97
|
+ BASE_DIR / "static",
|
|
|
98
|
+]
|
|
|
99
|
+
|
|
|
100
|
+MIDDLEWARE = [
|
|
|
101
|
+ 'django.middleware.security.SecurityMiddleware',
|
|
|
102
|
+ 'django.contrib.sessions.middleware.SessionMiddleware',
|
|
|
103
|
+ 'django.middleware.common.CommonMiddleware',
|
|
|
104
|
+ 'django.middleware.csrf.CsrfViewMiddleware',
|
|
|
105
|
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
|
106
|
+ 'ecoloop.middleware.WebAdminAuthRedirectMiddleware',
|
|
|
107
|
+ 'orgs.middleware.CurrentOrganizationMiddleware',
|
|
|
108
|
+ 'django.contrib.messages.middleware.MessageMiddleware',
|
|
|
109
|
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
|
110
|
+ "allauth.account.middleware.AccountMiddleware",
|
|
|
111
|
+]
|
|
|
112
|
+
|
|
|
113
|
+if DEBUG:
|
|
|
114
|
+ # Add django_browser_reload middleware only in DEBUG mode
|
|
|
115
|
+ MIDDLEWARE += [
|
|
|
116
|
+ "django_browser_reload.middleware.BrowserReloadMiddleware",
|
|
|
117
|
+ ]
|
|
|
118
|
+
|
|
|
119
|
+
|
|
|
120
|
+ROOT_URLCONF = 'ecoloop.urls'
|
|
|
121
|
+
|
|
|
122
|
+TEMPLATES = [
|
|
|
123
|
+ {
|
|
|
124
|
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
|
125
|
+ 'DIRS': [BASE_DIR / 'templates'],
|
|
|
126
|
+ 'APP_DIRS': True,
|
|
|
127
|
+ 'OPTIONS': {
|
|
|
128
|
+ 'context_processors': [
|
|
|
129
|
+ 'django.template.context_processors.debug',
|
|
|
130
|
+ 'django.template.context_processors.request',
|
|
|
131
|
+ 'django.contrib.auth.context_processors.auth',
|
|
|
132
|
+ 'django.contrib.messages.context_processors.messages',
|
|
|
133
|
+ 'ecoloop.context_processors.current_site',
|
|
|
134
|
+ ],
|
|
|
135
|
+ },
|
|
|
136
|
+ },
|
|
|
137
|
+]
|
|
|
138
|
+
|
|
|
139
|
+WSGI_APPLICATION = 'ecoloop.wsgi.application'
|
|
|
140
|
+
|
|
|
141
|
+
|
|
|
142
|
+# Database
|
|
|
143
|
+# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
|
|
144
|
+
|
|
|
145
|
+DATABASES = {
|
|
|
146
|
+ 'default': {
|
|
|
147
|
+ 'ENGINE': 'django.db.backends.postgresql',
|
|
|
148
|
+ 'NAME': os.getenv('DB_NAME'),
|
|
|
149
|
+ 'USER': os.getenv('DB_USER'),
|
|
|
150
|
+ 'PASSWORD': os.getenv('DB_PASSWORD'),
|
|
|
151
|
+ 'HOST': os.getenv('DB_HOST'),
|
|
|
152
|
+ 'PORT': os.getenv('DB_PORT'),
|
|
|
153
|
+ }
|
|
|
154
|
+}
|
|
|
155
|
+
|
|
|
156
|
+
|
|
|
157
|
+# Password validation
|
|
|
158
|
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
|
|
159
|
+
|
|
|
160
|
+AUTH_PASSWORD_VALIDATORS = [
|
|
|
161
|
+ {
|
|
|
162
|
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
|
163
|
+ },
|
|
|
164
|
+ {
|
|
|
165
|
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
|
166
|
+ },
|
|
|
167
|
+ {
|
|
|
168
|
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
|
169
|
+ },
|
|
|
170
|
+ {
|
|
|
171
|
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
|
172
|
+ },
|
|
|
173
|
+]
|
|
|
174
|
+
|
|
|
175
|
+
|
|
|
176
|
+# Internationalization
|
|
|
177
|
+# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
|
|
178
|
+
|
|
|
179
|
+LANGUAGE_CODE = 'en-us'
|
|
|
180
|
+
|
|
|
181
|
+TIME_ZONE = 'UTC'
|
|
|
182
|
+
|
|
|
183
|
+USE_I18N = True
|
|
|
184
|
+
|
|
|
185
|
+USE_TZ = True
|
|
|
186
|
+
|
|
|
187
|
+
|
|
|
188
|
+# Static and media files
|
|
|
189
|
+# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
|
|
190
|
+
|
|
|
191
|
+STATIC_URL = 'static/'
|
|
|
192
|
+
|
|
|
193
|
+# Media (uploaded) files
|
|
|
194
|
+MEDIA_URL = '/media/'
|
|
|
195
|
+MEDIA_ROOT = BASE_DIR / 'media'
|
|
|
196
|
+STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
|
|
197
|
+
|
|
|
198
|
+
|
|
|
199
|
+# Default primary key field type
|
|
|
200
|
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
|
|
201
|
+
|
|
|
202
|
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
|
203
|
+
|
|
|
204
|
+REST_FRAMEWORK = {
|
|
|
205
|
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
|
|
|
206
|
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
|
207
|
+ ),
|
|
|
208
|
+ 'DEFAULT_FILTER_BACKENDS': (
|
|
|
209
|
+ 'django_filters.rest_framework.DjangoFilterBackend',
|
|
|
210
|
+ ),
|
|
|
211
|
+ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
|
|
212
|
+}
|
|
|
213
|
+
|
|
|
214
|
+SPECTACULAR_SETTINGS = {
|
|
|
215
|
+ 'TITLE': 'Ecoloop API',
|
|
|
216
|
+ 'DESCRIPTION': 'API for the Ecoloop recycling management system.',
|
|
|
217
|
+ 'VERSION': '1.0.0',
|
|
|
218
|
+ 'SERVE_INCLUDE_SCHEMA': False,
|
|
|
219
|
+}
|
|
|
220
|
+
|
|
|
221
|
+# Auth redirects
|
|
|
222
|
+LOGIN_URL = '/webadmin/login/'
|
|
|
223
|
+LOGIN_REDIRECT_URL = '/webadmin/'
|
|
|
224
|
+
|
|
|
225
|
+# Taggit behavior
|
|
|
226
|
+TAGGIT_CASE_INSENSITIVE = True
|
|
|
227
|
+
|
|
|
228
|
+# django-allauth config
|
|
|
229
|
+SITE_ID = 1
|
|
|
230
|
+
|
|
|
231
|
+AUTHENTICATION_BACKENDS = [
|
|
|
232
|
+ 'django.contrib.auth.backends.ModelBackend',
|
|
|
233
|
+ 'allauth.account.auth_backends.AuthenticationBackend',
|
|
|
234
|
+]
|
|
|
235
|
+
|
|
|
236
|
+SOCIALACCOUNT_PROVIDERS = {
|
|
|
237
|
+ 'google': {
|
|
|
238
|
+ 'APP': {
|
|
|
239
|
+ 'client_id': os.getenv('GOOGLE_CLIENT_ID'),
|
|
|
240
|
+ 'secret': os.getenv('GOOGLE_CLIENT_SECRET'),
|
|
|
241
|
+ 'key': ''
|
|
|
242
|
+ },
|
|
|
243
|
+ 'SCOPE': [
|
|
|
244
|
+ 'profile',
|
|
|
245
|
+ 'email',
|
|
|
246
|
+ ],
|
|
|
247
|
+ 'AUTH_PARAMS': {
|
|
|
248
|
+ 'access_type': 'online',
|
|
|
249
|
+ }
|
|
|
250
|
+ },
|
|
|
251
|
+ 'line': {
|
|
|
252
|
+ 'APP': {
|
|
|
253
|
+ 'client_id': os.getenv('LINE_LOGIN_CHANNEL_ID'),
|
|
|
254
|
+ 'secret': os.getenv('LINE_LOGIN_CHANNEL_SECRET')
|
|
|
255
|
+ },
|
|
|
256
|
+ "SCOPE": ['profile', 'openid', 'email']
|
|
|
257
|
+ },
|
|
|
258
|
+ 'facebook': {
|
|
|
259
|
+ 'METHOD': 'oauth2', # Set to 'js_sdk' to use the Facebook connect SDK
|
|
|
260
|
+ 'SDK_URL': '//connect.facebook.net/{locale}/sdk.js',
|
|
|
261
|
+ 'SCOPE': ['email', 'public_profile'],
|
|
|
262
|
+ 'AUTH_PARAMS': {'auth_type': 'reauthenticate'},
|
|
|
263
|
+ 'INIT_PARAMS': {'cookie': True},
|
|
|
264
|
+ 'FIELDS': [
|
|
|
265
|
+ 'id',
|
|
|
266
|
+ 'first_name',
|
|
|
267
|
+ 'last_name',
|
|
|
268
|
+ 'middle_name',
|
|
|
269
|
+ 'name',
|
|
|
270
|
+ 'name_format',
|
|
|
271
|
+ 'picture',
|
|
|
272
|
+ 'short_name'
|
|
|
273
|
+ ],
|
|
|
274
|
+ 'EXCHANGE_TOKEN': True,
|
|
|
275
|
+ 'LOCALE_FUNC': 'path.to.callable',
|
|
|
276
|
+ 'VERIFIED_EMAIL': False,
|
|
|
277
|
+ 'VERSION': 'v13.0',
|
|
|
278
|
+ 'GRAPH_API_URL': 'https://graph.facebook.com/v13.0',
|
|
|
279
|
+ }
|
|
|
280
|
+}
|
|
|
281
|
+SITE_URL = "http://localhost:8000/"
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+"""
|
|
|
2
|
+URL configuration for lux_backend project.
|
|
|
3
|
+
|
|
|
4
|
+The `urlpatterns` list routes URLs to views. For more information please see:
|
|
|
5
|
+ https://docs.djangoproject.com/en/4.2/topics/http/urls/
|
|
|
6
|
+Examples:
|
|
|
7
|
+Function views
|
|
|
8
|
+ 1. Add an import: from my_app import views
|
|
|
9
|
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
|
10
|
+Class-based views
|
|
|
11
|
+ 1. Add an import: from other_app.views import Home
|
|
|
12
|
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
|
13
|
+Including another URLconf
|
|
|
14
|
+ 1. Import the include() function: from django.urls import include, path
|
|
|
15
|
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
|
16
|
+"""
|
|
|
17
|
+from django.contrib import admin
|
|
|
18
|
+from django.urls import path, include
|
|
|
19
|
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
|
|
20
|
+from django.conf import settings
|
|
|
21
|
+from django.conf.urls.static import static
|
|
|
22
|
+
|
|
|
23
|
+urlpatterns = [
|
|
|
24
|
+ path('', include('public_frontend.urls')),
|
|
|
25
|
+ path('lux/', include('frontend.urls')),
|
|
|
26
|
+ path('admin/', admin.site.urls),
|
|
|
27
|
+ path('api/', include('api.urls')),
|
|
|
28
|
+ path('auth/', include('djoser.urls')),
|
|
|
29
|
+ path('auth/', include('djoser.urls.jwt')),
|
|
|
30
|
+ path('accounts/', include('allauth.urls')),
|
|
|
31
|
+ path('webadmin/', include('admin_frontend.urls')),
|
|
|
32
|
+ path('webadmin/orgs/', include('orgs.urls_admin')),
|
|
|
33
|
+ path('webadmin/settings/', include('settings.urls')),
|
|
|
34
|
+ path('webadmin/cms/', include('cms.urls')),
|
|
|
35
|
+ path('webadmin/recycle/', include('recycle_core.urls')),
|
|
|
36
|
+ path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
|
|
37
|
+ path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
|
|
38
|
+ path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
|
|
39
|
+]
|
|
|
40
|
+
|
|
|
41
|
+if settings.DEBUG:
|
|
|
42
|
+ # Include django_browser_reload URLs only in DEBUG mode
|
|
|
43
|
+ urlpatterns += [
|
|
|
44
|
+ path("__reload__/", include("django_browser_reload.urls")),
|
|
|
45
|
+ ]
|
|
|
46
|
+ # Serve user-uploaded media files in development
|
|
|
47
|
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
|
48
|
+ from django.conf.urls.static import static
|
|
|
49
|
+ urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+"""
|
|
|
2
|
+WSGI config for lux_backend project.
|
|
|
3
|
+
|
|
|
4
|
+It exposes the WSGI callable as a module-level variable named ``application``.
|
|
|
5
|
+
|
|
|
6
|
+For more information on this file, see
|
|
|
7
|
+https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
|
|
8
|
+"""
|
|
|
9
|
+
|
|
|
10
|
+import os
|
|
|
11
|
+
|
|
|
12
|
+from django.core.wsgi import get_wsgi_application
|
|
|
13
|
+
|
|
|
14
|
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
|
|
|
15
|
+
|
|
|
16
|
+application = get_wsgi_application()
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+from django.contrib import admin
|
|
|
2
|
+
|
|
|
3
|
+# Register your models here.
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+from django.apps import AppConfig
|
|
|
2
|
+
|
|
|
3
|
+
|
|
|
4
|
+class FrontendConfig(AppConfig):
|
|
|
5
|
+ default_auto_field = 'django.db.models.BigAutoField'
|
|
|
6
|
+ name = 'frontend'
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+from django.db import models
|
|
|
2
|
+
|
|
|
3
|
+# Create your models here.
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+{% load static tailwind_tags %}
|
|
|
2
|
+<!DOCTYPE html>
|
|
|
3
|
+<html lang="en">
|
|
|
4
|
+<head>
|
|
|
5
|
+ <title>{% block title %}Lux Frontend{% endblock %}</title>
|
|
|
6
|
+ <meta charset="UTF-8">
|
|
|
7
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
8
|
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
|
9
|
+ {% tailwind_css %}
|
|
|
10
|
+</head>
|
|
|
11
|
+<body class="bg-gray-100 text-gray-900">
|
|
|
12
|
+ <nav class="bg-white shadow-md">
|
|
|
13
|
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
|
14
|
+ <div class="flex justify-between h-16">
|
|
|
15
|
+ <div class="flex">
|
|
|
16
|
+ <div class="flex-shrink-0 flex items-center">
|
|
|
17
|
+ <a href="{% url 'frontend:index' %}" class="text-2xl font-bold text-gray-900">Lux</a>
|
|
|
18
|
+ </div>
|
|
|
19
|
+ </div>
|
|
|
20
|
+ <div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
|
|
21
|
+ <a href="{% url 'frontend:profiles' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Profiles</a>
|
|
|
22
|
+ <a href="{% url 'frontend:opportunities' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Opportunities</a>
|
|
|
23
|
+ <a href="{% url 'frontend:leaderboard' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Leaderboard</a>
|
|
|
24
|
+ {% if user.is_authenticated %}
|
|
|
25
|
+ <a href="{% url 'frontend:logout' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Logout</a>
|
|
|
26
|
+ {% else %}
|
|
|
27
|
+ <a href="{% url 'frontend:login' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Login</a>
|
|
|
28
|
+ <a href="{% url 'frontend:signup' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Sign Up</a>
|
|
|
29
|
+ {% endif %}
|
|
|
30
|
+ </div>
|
|
|
31
|
+ </div>
|
|
|
32
|
+ </div>
|
|
|
33
|
+ </nav>
|
|
|
34
|
+
|
|
|
35
|
+ <main class="py-10">
|
|
|
36
|
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
|
37
|
+ {% block content %}{% endblock %}
|
|
|
38
|
+ </div>
|
|
|
39
|
+ </main>
|
|
|
40
|
+</body>
|
|
|
41
|
+</html>
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+{% extends 'frontend/base.html' %}
|
|
|
2
|
+
|
|
|
3
|
+{% block title %}Welcome to Lux{% endblock %}
|
|
|
4
|
+
|
|
|
5
|
+{% block content %}
|
|
|
6
|
+<div class="text-center">
|
|
|
7
|
+ <h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">Welcome to Lux</h1>
|
|
|
8
|
+ <p class="mt-6 text-lg leading-8 text-gray-600">The platform for professional connections and opportunities.</p>
|
|
|
9
|
+ <div class="mt-10 flex items-center justify-center gap-x-6">
|
|
|
10
|
+ <a href="{% url 'frontend:profiles' %}" class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">View Profiles</a>
|
|
|
11
|
+ <a href="{% url 'frontend:opportunities' %}" class="text-sm font-semibold leading-6 text-gray-900">View Opportunities <span aria-hidden="true">→</span></a>
|
|
|
12
|
+ </div>
|
|
|
13
|
+</div>
|
|
|
14
|
+{% endblock %}
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+{% extends 'frontend/base.html' %}
|
|
|
2
|
+
|
|
|
3
|
+{% block title %}Leaderboard{% endblock %}
|
|
|
4
|
+
|
|
|
5
|
+{% block content %}
|
|
|
6
|
+<h1 class="text-3xl font-bold mb-6">Leaderboard</h1>
|
|
|
7
|
+<div class="bg-white rounded-lg shadow-md">
|
|
|
8
|
+ <table class="min-w-full divide-y divide-gray-200">
|
|
|
9
|
+ <thead class="bg-gray-50">
|
|
|
10
|
+ <tr>
|
|
|
11
|
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
|
|
|
12
|
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
|
|
13
|
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Points</th>
|
|
|
14
|
+ </tr>
|
|
|
15
|
+ </thead>
|
|
|
16
|
+ <tbody class="bg-white divide-y divide-gray-200">
|
|
|
17
|
+ {% for row in leaderboard %}
|
|
|
18
|
+ <tr>
|
|
|
19
|
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ row.rank }}</td>
|
|
|
20
|
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ row.username }}</td>
|
|
|
21
|
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ row.points_given }}</td>
|
|
|
22
|
+ </tr>
|
|
|
23
|
+ {% endfor %}
|
|
|
24
|
+ </tbody>
|
|
|
25
|
+ </table>
|
|
|
26
|
+</div>
|
|
|
27
|
+{% endblock %}
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+{% extends 'frontend/base.html' %}
|
|
|
2
|
+
|
|
|
3
|
+{% block title %}Opportunities{% endblock %}
|
|
|
4
|
+
|
|
|
5
|
+{% block content %}
|
|
|
6
|
+<h1 class="text-3xl font-bold mb-6">Opportunities</h1>
|
|
|
7
|
+<div class="space-y-6">
|
|
|
8
|
+ {% for opportunity in opportunities %}
|
|
|
9
|
+ <div class="bg-white rounded-lg shadow-md p-6">
|
|
|
10
|
+ <h2 class="text-xl font-bold">{{ opportunity.title }}</h2>
|
|
|
11
|
+ <p class="text-gray-600 mt-2">{{ opportunity.description }}</p>
|
|
|
12
|
+ <p class="text-sm text-gray-500 mt-4">Posted on: {{ opportunity.created_at|date:"F d, Y" }}</p>
|
|
|
13
|
+ </div>
|
|
|
14
|
+ {% endfor %}
|
|
|
15
|
+</div>
|
|
|
16
|
+{% endblock %}
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+{% extends 'frontend/base.html' %}
|
|
|
2
|
+
|
|
|
3
|
+{% block title %}Profiles{% endblock %}
|
|
|
4
|
+
|
|
|
5
|
+{% block content %}
|
|
|
6
|
+<h1 class="text-3xl font-bold mb-6">Profiles</h1>
|
|
|
7
|
+<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
|
8
|
+ {% for profile in profiles %}
|
|
|
9
|
+ <div class="bg-white rounded-lg shadow-md p-6">
|
|
|
10
|
+ <h2 class="text-xl font-bold">{{ profile.user.username }}</h2>
|
|
|
11
|
+ <p class="text-gray-600">{{ profile.industry }}</p>
|
|
|
12
|
+ <p class="mt-4">{{ profile.bio }}</p>
|
|
|
13
|
+ <div class="mt-4">
|
|
|
14
|
+ {% for tag in profile.tags.all %}
|
|
|
15
|
+ <span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{{ tag.name }}</span>
|
|
|
16
|
+ {% endfor %}
|
|
|
17
|
+ </div>
|
|
|
18
|
+ </div>
|
|
|
19
|
+ {% endfor %}
|
|
|
20
|
+</div>
|
|
|
21
|
+{% endblock %}
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+from django.test import TestCase
|
|
|
2
|
+
|
|
|
3
|
+# Create your tests here.
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+from django.urls import path
|
|
|
2
|
+from . import views
|
|
|
3
|
+from django.contrib.auth import views as auth_views
|
|
|
4
|
+
|
|
|
5
|
+app_name = 'frontend'
|
|
|
6
|
+urlpatterns = [
|
|
|
7
|
+ path('', views.index, name='index'),
|
|
|
8
|
+ path('profiles/', views.profiles, name='profiles'),
|
|
|
9
|
+ path('opportunities/', views.opportunities, name='opportunities'),
|
|
|
10
|
+ path('leaderboard/', views.leaderboard, name='leaderboard'),
|
|
|
11
|
+ path('login/', auth_views.LoginView.as_view(template_name='frontend/login.html'), name='login'),
|
|
|
12
|
+ path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
|
|
13
|
+ path('signup/', views.signup, name='signup'),
|
|
|
14
|
+]
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+from django.shortcuts import render, redirect
|
|
|
2
|
+from django.contrib.auth import login
|
|
|
3
|
+from django.contrib.auth.forms import UserCreationForm
|
|
|
4
|
+from api.models import Profile, Opportunity
|
|
|
5
|
+from api.services.benefit import build_leaderboard_queryset
|
|
|
6
|
+
|
|
|
7
|
+def signup(request):
|
|
|
8
|
+ if request.method == 'POST':
|
|
|
9
|
+ form = UserCreationForm(request.POST)
|
|
|
10
|
+ if form.is_valid():
|
|
|
11
|
+ user = form.save()
|
|
|
12
|
+ login(request, user)
|
|
|
13
|
+ return redirect('frontend:index')
|
|
|
14
|
+ else:
|
|
|
15
|
+ form = UserCreationForm()
|
|
|
16
|
+ return render(request, 'frontend/signup.html', {'form': form})
|
|
|
17
|
+
|
|
|
18
|
+def index(request):
|
|
|
19
|
+ return render(request, 'frontend/index.html')
|
|
|
20
|
+
|
|
|
21
|
+def profiles(request):
|
|
|
22
|
+ profiles = Profile.objects.all()
|
|
|
23
|
+ return render(request, 'frontend/profiles.html', {'profiles': profiles})
|
|
|
24
|
+
|
|
|
25
|
+def opportunities(request):
|
|
|
26
|
+ opportunities = Opportunity.objects.all()
|
|
|
27
|
+ return render(request, 'frontend/opportunities.html', {'opportunities': opportunities})
|
|
|
28
|
+
|
|
|
29
|
+def leaderboard(request):
|
|
|
30
|
+ leaderboard = build_leaderboard_queryset('all')
|
|
|
31
|
+ return render(request, 'frontend/leaderboard.html', {'leaderboard': leaderboard})
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+#!/usr/bin/env python
|
|
|
2
|
+"""Django's command-line utility for administrative tasks."""
|
|
|
3
|
+import os
|
|
|
4
|
+import sys
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+def main():
|
|
|
8
|
+ """Run administrative tasks."""
|
|
|
9
|
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
|
|
|
10
|
+ try:
|
|
|
11
|
+ from django.core.management import execute_from_command_line
|
|
|
12
|
+ except ImportError as exc:
|
|
|
13
|
+ raise ImportError(
|
|
|
14
|
+ "Couldn't import Django. Are you sure it's installed and "
|
|
|
15
|
+ "available on your PYTHONPATH environment variable? Did you "
|
|
|
16
|
+ "forget to activate a virtual environment?"
|
|
|
17
|
+ ) from exc
|
|
|
18
|
+ execute_from_command_line(sys.argv)
|
|
|
19
|
+
|
|
|
20
|
+
|
|
|
21
|
+if __name__ == '__main__':
|
|
|
22
|
+ main()
|
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+default_app_config = "orgs.apps.OrgsConfig"
|
|
|
2
|
+
|