暫無描述

views.py 15KB

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