暂无描述

views.py 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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.urls import reverse
  6. from django.contrib.auth.models import User
  7. from django.contrib.auth.views import LoginView
  8. from api.models import Profile, Opportunity, IntroductionRequest
  9. from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
  10. import django_filters as filters
  11. from .forms import ProfileCreateForm, ProfileEditForm, OpportunityForm, IntroRequestForm
  12. from admin_frontend.templatetags.public_urls import public_route
  13. from api.services.matching import rank_users_for, rank_opportunities_for
  14. from api.services.benefit import build_leaderboard_queryset
  15. from .nav import _nav_items
  16. from orgs.models import OrganizationSite
  17. class SiteLoginView(LoginView):
  18. """Login view that redirects users to their organization's Site domain.
  19. After successful authentication, it looks up the user's organization via
  20. `user.recycle_profile.organization`, maps it to a `Site` domain via
  21. `OrganizationSite`, and returns an absolute URL to `/webadmin/` on that domain.
  22. Falls back to the default behavior if mapping is missing.
  23. """
  24. def get_success_url(self):
  25. url = super().get_success_url()
  26. try:
  27. user = self.request.user
  28. org = getattr(getattr(user, "recycle_profile", None), "organization", None)
  29. if org is None:
  30. return url
  31. mapping = OrganizationSite.objects.select_related("site").filter(organization=org).first()
  32. if not mapping or not mapping.site:
  33. return url
  34. domain = mapping.site.domain
  35. scheme = "https" if self.request.is_secure() else "http"
  36. host_header = self.request.get_host() or ""
  37. current_port = ""
  38. # Preserve incoming port from Host header if present (e.g., 8024 in Docker)
  39. if ":" in host_header:
  40. try:
  41. current_port = host_header.rsplit(":", 1)[1]
  42. except Exception:
  43. current_port = ""
  44. if current_port and not (
  45. (scheme == "http" and current_port == "80") or (scheme == "https" and current_port == "443")
  46. ):
  47. netloc = f"{domain}:{current_port}"
  48. else:
  49. netloc = domain
  50. return f"{scheme}://{netloc}/webadmin/"
  51. except Exception:
  52. return url
  53. @staff_member_required
  54. @public_route(label="Dashboard", order=10, icon="home")
  55. def dashboard(request):
  56. context = {
  57. "nav": _nav_items(),
  58. "counts": {
  59. "profiles": Profile.objects.count(),
  60. "opportunities": Opportunity.objects.count(),
  61. "intro_requests": IntroductionRequest.objects.count(),
  62. },
  63. }
  64. return render(request, "admin_frontend/dashboard.html", context)
  65. @staff_member_required
  66. @public_route(label="Profiles", order=20, icon="users")
  67. def profiles_list(request):
  68. # django-filter FilterSet for profiles
  69. class ProfileFilter(filters.FilterSet):
  70. username = filters.CharFilter(field_name="user__username", lookup_expr="icontains", label="Username")
  71. industry = filters.CharFilter(field_name="industry", lookup_expr="icontains", label="Industry")
  72. is_verified = filters.BooleanFilter(field_name="is_verified", label="Verified")
  73. tag = filters.CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag")
  74. class Meta:
  75. model = Profile
  76. fields = ["username", "industry", "is_verified", "tag"]
  77. def __init__(self, *args, **kwargs):
  78. super().__init__(*args, **kwargs)
  79. try:
  80. from crispy_forms.helper import FormHelper
  81. from crispy_forms.layout import Layout, Field
  82. helper = FormHelper()
  83. helper.form_tag = False
  84. helper.layout = Layout(
  85. Field("username", template="crispy/field_nowrap.html"),
  86. Field("industry", template="crispy/field_nowrap.html"),
  87. Field("tag", template="crispy/field_nowrap.html"),
  88. Field("is_verified", template="crispy/field_nowrap.html"),
  89. )
  90. self.form.helper = helper
  91. except Exception:
  92. # If crispy is unavailable for any reason, fail silently
  93. pass
  94. qs = Profile.objects.select_related("user").all().order_by("-id")
  95. prof_filter = ProfileFilter(request.GET, queryset=qs)
  96. paginator = Paginator(prof_filter.qs, 10)
  97. page = request.GET.get("page")
  98. try:
  99. page_obj = paginator.page(page)
  100. except PageNotAnInteger:
  101. page_obj = paginator.page(1)
  102. except EmptyPage:
  103. page_obj = paginator.page(paginator.num_pages)
  104. context = {"nav": _nav_items(), "profiles": page_obj.object_list, "page_obj": page_obj, "filter": prof_filter}
  105. return render(request, "admin_frontend/profiles_list.html", context)
  106. @staff_member_required
  107. @require_POST
  108. def profile_toggle_verify(request, pk: int):
  109. profile = get_object_or_404(Profile, pk=pk)
  110. profile.is_verified = not profile.is_verified
  111. profile.save(update_fields=["is_verified"])
  112. messages.success(request, f"Profile '{profile.user.username}' verification toggled.")
  113. return redirect(request.META.get("HTTP_REFERER") or reverse("admin_frontend:profiles_list"))
  114. @staff_member_required
  115. @public_route(label="Opportunities", order=30, icon="briefcase")
  116. def opportunities_list(request):
  117. if request.method == "POST":
  118. form = OpportunityForm(request.POST)
  119. if form.is_valid():
  120. form.save()
  121. messages.success(request, "Opportunity created.")
  122. return redirect("admin_frontend:opportunities_list")
  123. else:
  124. messages.error(request, "Please correct the errors below.")
  125. else:
  126. form = OpportunityForm()
  127. # django-filter FilterSet for opportunities
  128. class OpportunityFilter(filters.FilterSet):
  129. title = filters.CharFilter(field_name="title", lookup_expr="icontains", label="Title")
  130. created_at = filters.DateTimeFromToRangeFilter(
  131. field_name="created_at",
  132. label="Created between",
  133. widget=filters.widgets.RangeWidget(
  134. attrs={
  135. "type": "datetime-local",
  136. "style": "color-scheme: light;",
  137. "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",
  138. }
  139. ),
  140. )
  141. class Meta:
  142. model = Opportunity
  143. fields = ["title", "created_at"]
  144. opp_qs = Opportunity.objects.order_by("-created_at")
  145. opp_filter = OpportunityFilter(request.GET, queryset=opp_qs)
  146. paginator = Paginator(opp_qs, 10)
  147. page = request.GET.get("page")
  148. try:
  149. page_obj = Paginator(opp_filter.qs, 10).page(page)
  150. except PageNotAnInteger:
  151. page_obj = Paginator(opp_filter.qs, 10).page(1)
  152. except EmptyPage:
  153. # using paginator over filtered qs to get total pages
  154. filtered_paginator = Paginator(opp_filter.qs, 10)
  155. page_obj = filtered_paginator.page(filtered_paginator.num_pages)
  156. context = {
  157. "nav": _nav_items(),
  158. "opportunities": page_obj.object_list,
  159. "page_obj": page_obj,
  160. "form": form,
  161. "filter": opp_filter,
  162. }
  163. return render(request, "admin_frontend/opportunities_list.html", context)
  164. @staff_member_required
  165. @public_route(label="Intro Requests", order=40, icon="arrows-right-left")
  166. def introduction_requests_list(request):
  167. items = IntroductionRequest.objects.select_related("from_user", "to_user").order_by("-created_at")
  168. paginator = Paginator(items, 10)
  169. page = request.GET.get("page")
  170. try:
  171. page_obj = paginator.page(page)
  172. except PageNotAnInteger:
  173. page_obj = paginator.page(1)
  174. except EmptyPage:
  175. page_obj = paginator.page(paginator.num_pages)
  176. context = {"nav": _nav_items(), "requests": page_obj.object_list, "page_obj": page_obj}
  177. return render(request, "admin_frontend/intro_requests_list.html", context)
  178. @staff_member_required
  179. @public_route(label="Leaderboard", order=25, icon="trophy")
  180. def leaderboard(request):
  181. period = (request.GET.get("period") or "weekly").lower()
  182. try:
  183. limit = min(max(int(request.GET.get("limit", 25)), 1), 200)
  184. except Exception:
  185. limit = 25
  186. try:
  187. offset = max(int(request.GET.get("offset", 0)), 0)
  188. except Exception:
  189. offset = 0
  190. try:
  191. qs = build_leaderboard_queryset(period)
  192. rows = list(qs[offset : offset + limit])
  193. me = qs.filter(id=request.user.id).first()
  194. except Exception:
  195. rows = []
  196. me = None
  197. context = {
  198. "nav": _nav_items(),
  199. "period": period,
  200. "limit": limit,
  201. "offset": offset,
  202. "rows": rows,
  203. "me": me,
  204. "limit_options": [10, 25, 50, 100],
  205. }
  206. return render(request, "admin_frontend/leaderboard.html", context)
  207. @staff_member_required
  208. @require_POST
  209. def intro_request_accept(request, pk: int):
  210. item = get_object_or_404(IntroductionRequest, pk=pk)
  211. if not item.is_accepted:
  212. item.is_accepted = True
  213. item.save(update_fields=["is_accepted"])
  214. messages.success(request, "Introduction request accepted.")
  215. else:
  216. messages.info(request, "Already accepted.")
  217. return redirect(request.META.get("HTTP_REFERER") or reverse("admin_frontend:intro_requests_list"))
  218. @staff_member_required
  219. def opportunity_edit(request, pk: int):
  220. opp = get_object_or_404(Opportunity, pk=pk)
  221. if request.method == "POST":
  222. form = OpportunityForm(request.POST, instance=opp)
  223. if form.is_valid():
  224. form.save()
  225. messages.success(request, "Opportunity updated.")
  226. return redirect("admin_frontend:opportunity_edit", pk=opp.pk)
  227. else:
  228. form = OpportunityForm(instance=opp)
  229. context = {"nav": _nav_items(), "opportunity": opp, "form": form, "cancel_url": reverse("admin_frontend:opportunities_list")}
  230. return render(request, "admin_frontend/opportunity_form.html", context)
  231. @staff_member_required
  232. @require_POST
  233. def opportunity_delete(request, pk: int):
  234. opp = get_object_or_404(Opportunity, pk=pk)
  235. opp.delete()
  236. messages.success(request, "Opportunity deleted.")
  237. return redirect("admin_frontend:opportunities_list")
  238. # Profiles CRUD
  239. @staff_member_required
  240. def profile_create(request):
  241. if request.method == "POST":
  242. form = ProfileCreateForm(request.POST)
  243. if form.is_valid():
  244. form.save()
  245. messages.success(request, "Profile created.")
  246. return redirect("admin_frontend:profiles_list")
  247. else:
  248. form = ProfileCreateForm()
  249. return render(
  250. request,
  251. "admin_frontend/profile_form.html",
  252. {"nav": _nav_items(), "creating": True, "form": form, "cancel_url": reverse("admin_frontend:profiles_list")},
  253. )
  254. @staff_member_required
  255. def profile_edit(request, pk: int):
  256. profile = get_object_or_404(Profile, pk=pk)
  257. if request.method == "POST":
  258. form = ProfileEditForm(request.POST, instance=profile)
  259. if form.is_valid():
  260. form.save()
  261. messages.success(request, "Profile updated.")
  262. return redirect("admin_frontend:profile_edit", pk=profile.pk)
  263. else:
  264. form = ProfileEditForm(instance=profile)
  265. # Suggestions for this user (top 5)
  266. try:
  267. user_matches = rank_users_for(profile.user, k=5)
  268. except Exception:
  269. user_matches = []
  270. try:
  271. opp_recs = rank_opportunities_for(profile.user, k=5)
  272. except Exception:
  273. opp_recs = []
  274. return render(
  275. request,
  276. "admin_frontend/profile_form.html",
  277. {
  278. "nav": _nav_items(),
  279. "profile": profile,
  280. "creating": False,
  281. "form": form,
  282. "cancel_url": reverse("admin_frontend:profiles_list"),
  283. "user_matches": user_matches,
  284. "opp_recs": opp_recs,
  285. },
  286. )
  287. @staff_member_required
  288. @require_POST
  289. def profile_delete(request, pk: int):
  290. profile = get_object_or_404(Profile, pk=pk)
  291. profile.delete()
  292. messages.success(request, "Profile deleted.")
  293. return redirect("admin_frontend:profiles_list")
  294. # Intro Requests CRUD
  295. @staff_member_required
  296. def intro_request_create(request):
  297. if request.method == "POST":
  298. form = IntroRequestForm(request.POST)
  299. if form.is_valid():
  300. form.save()
  301. messages.success(request, "Introduction request created.")
  302. return redirect("admin_frontend:intro_requests_list")
  303. else:
  304. form = IntroRequestForm()
  305. return render(
  306. request,
  307. "admin_frontend/intro_request_form.html",
  308. {"nav": _nav_items(), "creating": True, "form": form, "cancel_url": reverse("admin_frontend:intro_requests_list")},
  309. )
  310. @staff_member_required
  311. def intro_request_edit(request, pk: int):
  312. item = get_object_or_404(IntroductionRequest, pk=pk)
  313. if request.method == "POST":
  314. form = IntroRequestForm(request.POST, instance=item)
  315. if form.is_valid():
  316. form.save(instance=item)
  317. messages.success(request, "Introduction request updated.")
  318. return redirect("admin_frontend:intro_request_edit", pk=item.pk)
  319. else:
  320. form = IntroRequestForm(instance=item)
  321. context = {"nav": _nav_items(), "item": item, "creating": False, "form": form, "cancel_url": reverse("admin_frontend:intro_requests_list")}
  322. return render(request, "admin_frontend/intro_request_form.html", context)
  323. @staff_member_required
  324. @require_POST
  325. def intro_request_delete(request, pk: int):
  326. item = get_object_or_404(IntroductionRequest, pk=pk)
  327. item.delete()
  328. messages.success(request, "Introduction request deleted.")
  329. return redirect("admin_frontend:intro_requests_list")
  330. ## Recycle Core admin UI moved to recycle_core app