nes-num-new"> 23
+              <a class="hover:text-black/70" href="{% url 'admin_frontend:leaderboard' %}">Leaderboard</a>
24
+            </div>
25
+          </div>
26
+          <div class="flex items-center gap-3">
27
+            {% if request.user.is_authenticated %}
28
+              <span class="text-sm text-gray-600">{{ request.user.username }}</span>
29
+              <form method="post" action="{% url 'admin_frontend:logout' %}">
30
+                {% csrf_token %}
31
+                <button class="text-sm text-red-600 hover:text-red-700">Logout</button>
32
+              </form>
33
+            {% else %}
34
+              <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'admin_frontend:login' %}">Login</a>
35
+            {% endif %}
36
+          </div>
37
+        </div>
38
+      </div>
39
+    </nav>
40
+
41
+    <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
42
+      {% if messages %}
43
+        <div id="toasts" class="fixed bottom-4 right-4 z-50 space-y-2">
44
+          {% for message in messages %}
45
+            <div class="toast px-4 py-3 rounded border shadow {{ message.tags|default:'' }}">
46
+              {{ message }}
47
+            </div>
48
+          {% endfor %}
49
+        </div>
50
+      {% endif %}
51
+
52
+      <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
53
+        <aside class="md:col-span-3">
54
+          <nav class="bg-white rounded shadow p-4">
55
+            <div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Navigation</div>
56
+            {% app_menu 'admin_frontend' as menu_items %}
57
+            <ul class="space-y-1">
58
+              {% for it in menu_items %}
59
+                <li>
60
+                  <a href="{{ it.url }}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == it.url %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
61
+                    <span class="truncate">{{ it.label }}</span>
62
+                  </a>
63
+                </li>
64
+              {% endfor %}
65
+              <div x-data="{ open: {% if '/webadmin/settings/' in request.path %}true{% else %}false{% endif %} }">
66
+                  <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
67
+                      <span class="truncate">Settings</span>
68
+                      <svg :class="{'rotate-180': open}" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
69
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
70
+                      </svg>
71
+                  </div>
72
+                  <div x-show="open" x-cloak class="pl-4">
73
+                      {% app_menu 'settings' as settings_menu_items %}
74
+                      <ul class="space-y-1">
75
+                        {% for it in settings_menu_items %}
76
+                          <li>
77
+                            <a href="{{ it.url }}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == it.url %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
78
+                              <span class="truncate">{{ it.label }}</span>
79
+                            </a>
80
+                          </li>
81
+                        {% endfor %}
82
+                      </ul>
83
+                  </div>
84
+              </div>
85
+              <div x-data="{ open: {% if '/webadmin/cms/' in request.path %}true{% else %}false{% endif %} }">
86
+                  <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
87
+                      <span class="truncate">CMS</span>
88
+                      <svg :class="{'rotate-180': open}" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
89
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
90
+                      </svg>
91
+                  </div>
92
+                  <div x-show="open" x-cloak class="pl-4">
93
+                      {% app_menu 'cms' as cms_menu_items %}
94
+                      <ul class="space-y-1">
95
+                        {% for it in cms_menu_items %}
96
+                          <li>
97
+                            <a href="{{ it.url }}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == it.url %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
98
+                              <span class="truncate">{{ it.label }}</span>
99
+                            </a>
100
+                          </li>
101
+                        {% endfor %}
102
+                      </ul>
103
+                  </div>
104
+              </div>
105
+              <div x-data="{ open: {% if '/webadmin/orgs/' in request.path %}true{% else %}false{% endif %} }">
106
+                  <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
107
+                      <span class="truncate">Organization</span>
108
+                      <svg :class="{'rotate-180': open}" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
109
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
110
+                      </svg>
111
+                  </div>
112
+                  <div x-show="open" x-cloak class="pl-4">
113
+                      <ul class="space-y-1">
114
+                        <li>
115
+                          <a href="{% url 'orgs_admin:org_settings' %}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == '/webadmin/orgs/settings/' %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
116
+                            <span class="truncate">Organization Settings</span>
117
+                          </a>
118
+                        </li>
119
+                        <li>
120
+                          <a href="{% url 'orgs_admin:org_sites' %}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == '/webadmin/orgs/sites/' %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
121
+                            <span class="truncate">Organization Sites</span>
122
+                          </a>
123
+                        </li>
124
+                      </ul>
125
+                  </div>
126
+              </div>
127
+              <div x-data="{ open: {% if '/webadmin/recycle/' in request.path %}true{% else %}false{% endif %} }">
128
+                  <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
129
+                      <span class="truncate">Recycle Ops</span>
130
+                      <svg :class="{'rotate-180': open}" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
131
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
132
+                      </svg>
133
+                  </div>
134
+                  <div x-show="open" x-cloak class="pl-4">
135
+                      {% app_menu 'recycle_core' as recycle_menu_items %}
136
+                      <ul class="space-y-1">
137
+                        {% for it in recycle_menu_items %}
138
+                          <li>
139
+                            <a href="{{ it.url }}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == it.url %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
140
+                              <span class="truncate">{{ it.label }}</span>
141
+                            </a>
142
+                          </li>
143
+                        {% endfor %}
144
+                      </ul>
145
+                  </div>
146
+              </div>
147
+            </ul>
148
+          </nav>
149
+        </aside>
150
+        <section class="md:col-span-9">
151
+          {% block content %}{% endblock %}
152
+        </section>
153
+      </div>
154
+    </main>
155
+    <footer class="bg-white border-t border-gray-200">
156
+      <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
157
+        <div class="flex h-16 items-center justify-between">
158
+          <p class="text-sm text-gray-600">&copy; {% now "Y" %} Lux Backend. All rights reserved.</p>
159
+        </div>
160
+      </div>
161
+    </footer>
162
+  </div>
163
+  {% block scripts %}{% endblock %}
164
+  <!-- Flowbite JS (components and interactivity) -->
165
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.1/flowbite.min.js"></script>
166
+  <script>
167
+    // Auto-fade toasts after 5 seconds
168
+    window.addEventListener('DOMContentLoaded', () => {
169
+      document.querySelectorAll('#toasts .toast').forEach((el) => {
170
+        setTimeout(() => {
171
+          el.style.opacity = '0';
172
+          el.style.transition = 'opacity 0.5s ease';
173
+          setTimeout(() => el.remove(), 600);
174
+        }, 5000);
175
+      });
176
+    });
177
+  </script>
178
+    <style>
179
+      /* Ensure system UI controls (date/time pickers) render with dark icons on light background */
180
+      input[type="date"], input[type="time"], input[type="datetime-local"] { color-scheme: light; }
181
+      /* Hide cloaked elements until Alpine initializes */
182
+      [x-cloak] { display: none !important; }
183
+      .success { background-color: #ecfdf5; border-color:#34d399; color:#065f46; }
184
+      .error { background-color: #fef2f2; border-color:#fca5a5; color:#7f1d1d; }
185
+      .info { background-color: #eff6ff; border-color:#93c5fd; color:#1e3a8a; }
186
+      .toast { opacity: 1; }
187
+
188
+    #div_id_is_verified .mb-3 { margin-bottom: 0px !important; }
189
+
190
+    /* Fallback color for outline buttons when --btn-color is unset */
191
+  </style>
192
+</body>
193
+</html>

+ 22 - 0
admin_frontend/templates/admin_frontend/dashboard.html

@@ -0,0 +1,22 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Dashboard{% endblock %}
3
+{% block content %}
4
+
5
+
6
+<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
7
+<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
8
+  <div class="bg-white rounded shadow p-4">
9
+    <div class="text-gray-500 text-sm">Profiles</div>
10
+    <div class="text-3xl font-bold">{{ counts.profiles }}</div>
11
+  </div>
12
+  <div class="bg-white rounded shadow p-4">
13
+    <div class="text-gray-500 text-sm">Opportunities</div>
14
+    <div class="text-3xl font-bold">{{ counts.opportunities }}</div>
15
+  </div>
16
+  <div class="bg-white rounded shadow p-4">
17
+    <div class="text-gray-500 text-sm">Introduction Requests</div>
18
+    <div class="text-3xl font-bold">{{ counts.intro_requests }}</div>
19
+  </div>
20
+  </div>
21
+{% endblock %}
22
+

+ 11 - 0
admin_frontend/templates/admin_frontend/global_settings.html

@@ -0,0 +1,11 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
3
+{% block content %}
4
+<div class="prose">
5
+    <h1>Global Settings</h1>
6
+    <form method="post">
7
+        {% crispy form %}
8
+        <button type="submit" class="btn btn-primary">Save</button>
9
+    </form>
10
+</div>
11
+{% endblock %}

+ 8 - 0
admin_frontend/templates/admin_frontend/intro_request_form.html

@@ -0,0 +1,8 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}{% if creating %}New Intro Request{% else %}Edit Intro Request{% endif %}{% endblock %}
3
+{% block content %}
4
+<div class="max-w-2xl">
5
+  <h1 class="text-2xl font-semibold mb-4">{% if creating %}New Intro Request{% else %}Edit Intro Request{% endif %}</h1>
6
+  {% include 'admin_frontend/_form.html' with form=form submit_label='Save' cancel_url=cancel_url %}
7
+</div>
8
+{% endblock %}

+ 55 - 0
admin_frontend/templates/admin_frontend/intro_requests_list.html

@@ -0,0 +1,55 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Introduction Requests{% endblock %}
3
+{% block content %}
4
+<div class="flex items-center justify-between mb-4">
5
+  <h1 class="text-2xl font-semibold">Introduction Requests</h1>
6
+  <a href="{% url 'admin_frontend:intro_request_create' %}" class="btn">New</a>
7
+  </div>
8
+<div class="bg-white rounded shadow overflow-hidden">
9
+  <table class="min-w-full divide-y divide-gray-200">
10
+    <thead class="bg-gray-50">
11
+      <tr>
12
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">From</th>
13
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">To</th>
14
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Message</th>
15
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Status</th>
16
+        <th class="px-4 py-2"></th>
17
+      </tr>
18
+    </thead>
19
+    <tbody class="divide-y divide-gray-200">
20
+      {% for r in requests %}
21
+      <tr>
22
+        <td class="px-4 py-2">{{ r.from_user.username }}</td>
23
+        <td class="px-4 py-2">{{ r.to_user.username }}</td>
24
+        <td class="px-4 py-2">{{ r.message }}</td>
25
+        <td class="px-4 py-2">
26
+          {% if r.is_accepted %}
27
+            <span class="inline-flex items-center gap-1 text-green-700"><span class="w-2 h-2 bg-green-600 rounded-full"></span> Accepted</span>
28
+          {% else %}
29
+            <span class="text-gray-500">Pending</span>
30
+          {% endif %}
31
+        </td>
32
+        <td class="px-4 py-2 text-right">
33
+          <div class="flex gap-2 justify-end">
34
+            {% if not r.is_accepted %}
35
+              <form method="post" action="{% url 'admin_frontend:intro_request_accept' r.pk %}">
36
+                {% csrf_token %}
37
+                <button class="btn-outline">Accept</button>
38
+              </form>
39
+            {% endif %}
40
+            <a class="btn-outline" href="{% url 'admin_frontend:intro_request_edit' r.pk %}">Edit</a>
41
+            <form method="post" action="{% url 'admin_frontend:intro_request_delete' r.pk %}" onsubmit="return confirm('Delete this request?');">
42
+              {% csrf_token %}
43
+              <button class="btn-outline text-red-600">Delete</button>
44
+            </form>
45
+          </div>
46
+        </td>
47
+      </tr>
48
+      {% empty %}
49
+      <tr><td class="px-4 py-4 text-center text-gray-500" colspan="5">No introduction requests.</td></tr>
50
+      {% endfor %}
51
+    </tbody>
52
+  </table>
53
+  {% include 'admin_frontend/_pagination.html' %}
54
+  </div>
55
+{% endblock %}

+ 64 - 0
admin_frontend/templates/admin_frontend/leaderboard.html

@@ -0,0 +1,64 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Leaderboard{% endblock %}
3
+{% block content %}
4
+<div class="flex items-center justify-between mb-4">
5
+  <h1 class="text-2xl font-semibold">Leaderboard</h1>
6
+  <form method="get" class="flex items-center gap-2">
7
+    <label class="text-sm text-gray-600">Period</label>
8
+    <select name="period" class="border rounded px-3 py-2">
9
+      <option value="weekly" {% if period == 'weekly' %}selected{% endif %}>Weekly</option>
10
+      <option value="month" {% if period == 'month' or period == 'monthly' or period == '30d' %}selected{% endif %}>Monthly</option>
11
+      <option value="all" {% if period == 'all' or period == 'all_time' or period == 'alltime' or period == '' %}selected{% endif %}>All Time</option>
12
+    </select>
13
+    <label class="text-sm text-gray-600">Show</label>
14
+    <select name="limit" class="border rounded px-3 py-2">
15
+      {% for n in limit_options %}
16
+      <option value="{{ n }}" {% if limit == n %}selected{% endif %}>{{ n }}</option>
17
+      {% endfor %}
18
+    </select>
19
+    <button class="btn">Apply</button>
20
+  </form>
21
+</div>
22
+
23
+{% if me %}
24
+<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
25
+  <div class="bg-white rounded shadow p-4">
26
+    <div class="text-sm text-gray-500">Your Rank</div>
27
+    <div class="text-2xl font-semibold">#{{ me.rank|default:'—' }}</div>
28
+  </div>
29
+  <div class="bg-white rounded shadow p-4">
30
+    <div class="text-sm text-gray-500">Your Points</div>
31
+    <div class="text-2xl font-semibold">{{ me.points_given|default:0 }}</div>
32
+  </div>
33
+  <div class="bg-white rounded shadow p-4">
34
+    <div class="text-sm text-gray-500">Last Helped</div>
35
+    <div class="text-2xl font-semibold">{% if me.last_help_at %}{{ me.last_help_at|date:'Y-m-d H:i' }}{% else %}—{% endif %}</div>
36
+  </div>
37
+  </div>
38
+{% endif %}
39
+
40
+<div class="bg-white rounded shadow overflow-hidden">
41
+  <table class="min-w-full divide-y divide-gray-200">
42
+    <thead class="bg-gray-50">
43
+      <tr>
44
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Rank</th>
45
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">User</th>
46
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Points</th>
47
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Last Help</th>
48
+      </tr>
49
+    </thead>
50
+    <tbody class="divide-y divide-gray-200">
51
+      {% for u in rows %}
52
+      <tr class="{% if me and u.id == me.id %}bg-blue-50{% endif %}">
53
+        <td class="px-4 py-2 font-medium">#{{ u.rank }}</td>
54
+        <td class="px-4 py-2">{{ u.username }}</td>
55
+        <td class="px-4 py-2">{{ u.points_given|default:0 }}</td>
56
+        <td class="px-4 py-2">{% if u.last_help_at %}{{ u.last_help_at|date:'Y-m-d H:i' }}{% else %}<span class="text-gray-400">—</span>{% endif %}</td>
57
+      </tr>
58
+      {% empty %}
59
+      <tr><td class="px-4 py-4 text-center text-gray-500" colspan="4">No leaderboard data for this period.</td></tr>
60
+      {% endfor %}
61
+    </tbody>
62
+  </table>
63
+</div>
64
+{% endblock %}

+ 15 - 0
admin_frontend/templates/admin_frontend/login.html

@@ -0,0 +1,15 @@
1
+{% extends 'admin_frontend/plain_base.html' %}
2
+{% load tailwind_filters %}
3
+{% block title %}Login{% endblock %}
4
+{% block content %}
5
+<div class="max-w-md mx-auto">
6
+  <div class="bg-white shadow rounded p-6">
7
+    <h1 class="text-xl font-semibold mb-4">Sign in</h1>
8
+    <form method="post">
9
+      {% csrf_token %}
10
+      {{ form|crispy }}
11
+      <button class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Login</button>
12
+    </form>
13
+  </div>
14
+</div>
15
+{% endblock %}

+ 62 - 0
admin_frontend/templates/admin_frontend/opportunities_list.html

@@ -0,0 +1,62 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% load tailwind_filters %}
3
+{% block title %}Opportunities{% endblock %}
4
+{% block content %}
5
+<div class="flex items-center justify-between mb-4">
6
+  <h1 class="text-2xl font-semibold">Opportunities</h1>
7
+  <details class="relative">
8
+    <summary class="cursor-pointer px-3 py-2 bg-blue-600 text-white rounded">New</summary>
9
+    <div class="absolute right-0 mt-2 bg-white shadow rounded p-4 w-80">
10
+      {% include 'admin_frontend/_form.html' with form=form submit_label='Create' %}
11
+    </div>
12
+  </details>
13
+</div>
14
+
15
+<div class="bg-white rounded shadow p-4 mb-4">
16
+  {% load crispy_forms_tags %}
17
+  <form method="get" class="space-y-3 flex gap-2">
18
+    {{ filter.form|crispy }}
19
+    <div class="flex items-center gap-2">
20
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
21
+      <a href="{% url 'admin_frontend:opportunities_list' %}" class="btn-outline">Reset</a>
22
+    </div>
23
+  </form>
24
+  {% if filter.qs.count != page_obj.paginator.count %}
25
+    <p class="text-sm text-gray-500 mt-2">Filtered results: {{ page_obj.paginator.count }} items</p>
26
+  {% endif %}
27
+</div>
28
+<div class="bg-white rounded shadow overflow-hidden">
29
+  <table class="min-w-full divide-y divide-gray-200">
30
+    <thead class="bg-gray-50">
31
+      <tr>
32
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Title</th>
33
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Description</th>
34
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Created</th>
35
+      </tr>
36
+    </thead>
37
+    <tbody class="divide-y divide-gray-200">
38
+      {% for o in opportunities %}
39
+      <tr>
40
+        <td class="px-4 py-2 font-medium">{{ o.title }}</td>
41
+        <td class="px-4 py-2">{{ o.description }}</td>
42
+        <td class="px-4 py-2 text-gray-500">{{ o.created_at }}</td>
43
+      </tr>
44
+      <tr>
45
+        <td colspan="3" class="px-4 py-2">
46
+          <div class="flex gap-2 justify-end">
47
+            <a href="{% url 'admin_frontend:opportunity_edit' o.pk %}" class="btn-outline">Edit</a>
48
+            <form method="post" action="{% url 'admin_frontend:opportunity_delete' o.pk %}" onsubmit="return confirm('Delete this opportunity?');">
49
+              {% csrf_token %}
50
+              <button class="btn-outline text-red-600">Delete</button>
51
+            </form>
52
+          </div>
53
+        </td>
54
+      </tr>
55
+      {% empty %}
56
+      <tr><td class="px-4 py-4 text-center text-gray-500" colspan="3">No opportunities yet.</td></tr>
57
+      {% endfor %}
58
+    </tbody>
59
+  </table>
60
+{% include 'admin_frontend/_pagination.html' %}
61
+</div>
62
+{% endblock %}

+ 8 - 0
admin_frontend/templates/admin_frontend/opportunity_form.html

@@ -0,0 +1,8 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Edit Opportunity{% endblock %}
3
+{% block content %}
4
+<div class="max-w-2xl">
5
+  <h1 class="text-2xl font-semibold mb-4">Edit Opportunity</h1>
6
+  {% include 'admin_frontend/_form.html' with form=form submit_label='Save' cancel_url=cancel_url %}
7
+</div>
8
+{% endblock %}

+ 75 - 0
admin_frontend/templates/admin_frontend/plain_base.html

@@ -0,0 +1,75 @@
1
+<!DOCTYPE html>
2
+<html lang="en" x-data="{ open:false }">
3
+<head>
4
+  <meta charset="utf-8" />
5
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+  <title>Web Admin — {% block title %}{% endblock %}</title>
7
+  {% load tailwind_tags public_urls  %}
8
+  {% tailwind_css %}
9
+  <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
10
+</head>
11
+<body class="bg-gray-50 text-gray-900">
12
+  <div class="min-h-screen">
13
+    <nav class="bg-white border-b border-gray-200">
14
+      <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
15
+        <div class="flex h-16 items-center justify-between">
16
+          <div class="flex items-center gap-8">
17
+            <a href="/webadmin/" class="text-lg font-semibold">Web Admin</a>
18
+            <div class="hidden md:flex items-center gap-4">
19
+              <a class="hover:text-black/70" href="{% url 'admin_frontend:dashboard' %}">Dashboard</a>
20
+              <a class="hover:text-black/70" href="{% url 'admin_frontend:profiles_list' %}">Profiles</a>
21
+              <a class="hover:text-black/70" href="{% url 'admin_frontend:opportunities_list' %}">Opportunities</a>
22
+              <a class="hover:text-black/70" href="{% url 'admin_frontend:intro_requests_list' %}">Intro Requests</a>
23
+            </div>
24
+          </div>
25
+          <div class="flex items-center gap-3">
26
+            {% if request.user.is_authenticated %}
27
+              <span class="text-sm text-gray-600">{{ request.user.username }}</span>
28
+              <form method="post" action="{% url 'admin_frontend:logout' %}">
29
+                {% csrf_token %}
30
+                <button class="text-sm text-red-600 hover:text-red-700">Logout</button>
31
+              </form>
32
+            {% else %}
33
+              <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'admin_frontend:login' %}">Login</a>
34
+            {% endif %}
35
+          </div>
36
+        </div>
37
+      </div>
38
+    </nav>
39
+
40
+    <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
41
+      {% if messages %}
42
+        <div class="space-y-2 mb-4">
43
+          {% for message in messages %}
44
+            <div class="px-4 py-3 rounded border {{ message.tags|default:'' }}"
45
+                 :class="{'bg-green-50 border-green-300 text-green-800': '{{ message.tags }}'.includes('success'), 'bg-red-50 border-red-300 text-red-800': '{{ message.tags }}'.includes('error'), 'bg-blue-50 border-blue-300 text-blue-800': '{{ message.tags }}'.includes('info')}">
46
+              {{ message }}
47
+            </div>
48
+          {% endfor %}
49
+        </div>
50
+      {% endif %}
51
+
52
+      {% block content %}{% endblock %}
53
+    </main>
54
+    <footer class="bg-white border-t border-gray-200">
55
+      <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
56
+        <div class="flex h-16 items-center justify-between">
57
+          <p class="text-sm text-gray-600">&copy; {% now "Y" %} Lux Backend. All rights reserved.</p>
58
+        </div>
59
+      </div>
60
+    </footer>
61
+  </div>
62
+  {% block scripts %}{% endblock %}
63
+  <!-- Flowbite JS (components and interactivity) -->
64
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.1/flowbite.min.js"></script>
65
+  <style>
66
+    /* Ensure system UI controls (date/time pickers) render with dark icons on light background */
67
+    input[type="date"], input[type="time"], input[type="datetime-local"] { color-scheme: light; }
68
+    .success { background-color: #ecfdf5; border-color:#34d399; color:#065f46; }
69
+    .error { background-color: #fef2f2; border-color:#fca5a5; color:#7f1d1d; }
70
+    .info { background-color: #eff6ff; border-color:#93c5fd; color:#1e3a8a; }
71
+
72
+    #div_id_is_verified .mb-3 { margin-bottom: 0px !important; }
73
+  </style>
74
+</body>
75
+</html>

+ 49 - 0
admin_frontend/templates/admin_frontend/profile_form.html

@@ -0,0 +1,49 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}{% if creating %}New Profile{% else %}Edit Profile{% endif %}{% endblock %}
3
+{% block content %}
4
+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
5
+  <div class="lg:col-span-2">
6
+    <h1 class="text-2xl font-semibold mb-4">{% if creating %}New Profile{% else %}Edit Profile{% endif %}</h1>
7
+    {% if not creating %}
8
+      <div class="bg-white rounded shadow p-6 mb-4">
9
+        <label class="text-sm font-medium">User</label>
10
+        <input value="{{ profile.user.username }}" disabled class="mt-1 block w-full rounded-md border-gray-200 bg-gray-100" />
11
+      </div>
12
+    {% endif %}
13
+    {% include 'admin_frontend/_form.html' with form=form submit_label='Save' cancel_url=cancel_url %}
14
+  </div>
15
+  {% if not creating %}
16
+  <aside class="space-y-6">
17
+    <div class="bg-white rounded shadow p-4">
18
+      <h2 class="text-lg font-semibold mb-3">Suggested Matches</h2>
19
+      <ul class="divide-y divide-gray-200">
20
+        {% for item in user_matches %}
21
+          <li class="py-2 flex items-center justify-between">
22
+            <div>
23
+              <div class="font-medium">{{ item.profile.user.username }}</div>
24
+              <div class="text-sm text-gray-500">Score: {{ item.score|floatformat:3 }}</div>
25
+            </div>
26
+            <a class="btn-outline" href="{% url 'admin_frontend:profile_edit' item.profile.pk %}">View</a>
27
+          </li>
28
+        {% empty %}
29
+          <li class="py-2 text-gray-500">No matches yet.</li>
30
+        {% endfor %}
31
+      </ul>
32
+    </div>
33
+    <div class="bg-white rounded shadow p-4">
34
+      <h2 class="text-lg font-semibold mb-3">Recommended Opportunities</h2>
35
+      <ul class="divide-y divide-gray-200">
36
+        {% for item in opp_recs %}
37
+          <li class="py-2">
38
+            <div class="font-medium">{{ item.opportunity.title }}</div>
39
+            <div class="text-sm text-gray-500">Score: {{ item.score|floatformat:3 }}</div>
40
+          </li>
41
+        {% empty %}
42
+          <li class="py-2 text-gray-500">No recommendations.</li>
43
+        {% endfor %}
44
+      </ul>
45
+    </div>
46
+  </aside>
47
+  {% endif %}
48
+</div>
49
+{% endblock %}

+ 72 - 0
admin_frontend/templates/admin_frontend/profiles_list.html

@@ -0,0 +1,72 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Profiles{% endblock %}
3
+{% block content %}
4
+<div class="flex items-center justify-between mb-4">
5
+  <h1 class="text-2xl font-semibold">Profiles</h1>
6
+  <div class="flex items-center gap-3">
7
+    <a href="{% url 'admin_frontend:profile_create' %}" class="btn">New</a>
8
+  </div>
9
+  </div>
10
+
11
+<div class="bg-white rounded shadow p-4 mb-4">
12
+  {% load crispy_forms_tags %}
13
+  <form method="get" class="flex items-center gap-2 ">
14
+    {{ filter.form|crispy }}
15
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
16
+      <a href="{% url 'admin_frontend:profiles_list' %}" class="btn-outline">Reset</a>
17
+  </form>
18
+</div>
19
+<div class="bg-white rounded shadow overflow-hidden">
20
+  <table class="min-w-full divide-y divide-gray-200">
21
+    <thead class="bg-gray-50">
22
+      <tr>
23
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">User</th>
24
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Interests / Tags / Industry</th>
25
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Verified</th>
26
+        <th class="px-4 py-2"></th>
27
+    </tr>
28
+    </thead>
29
+    <tbody class="divide-y divide-gray-200">
30
+      {% for p in profiles %}
31
+      <tr>
32
+        <td class="px-4 py-2">{{ p.user.username }}</td>
33
+        <td class="px-4 py-2">
34
+          <div class="text-sm text-gray-700">{{ p.interests }}</div>
35
+          <div class="mt-1 flex flex-wrap gap-1">
36
+            {% if p.industry %}
37
+              <span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200">{{ p.industry }}</span>
38
+            {% endif %}
39
+            {% for t in p.tags.all %}
40
+              <span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700 border border-blue-200">{{ t.name }}</span>
41
+            {% empty %}{% endfor %}
42
+          </div>
43
+        </td>
44
+        <td class="px-4 py-2">
45
+          {% if p.is_verified %}
46
+            <span class="inline-flex items-center gap-1 text-green-700"><span class="w-2 h-2 bg-green-600 rounded-full"></span> Verified</span>
47
+          {% else %}
48
+            <span class="text-gray-500">No</span>
49
+          {% endif %}
50
+        </td>
51
+        <td class="px-4 py-2 text-right">
52
+          <div class="flex gap-2 justify-end">
53
+            <form method="post" action="{% url 'admin_frontend:profile_toggle_verify' p.pk %}">
54
+              {% csrf_token %}
55
+              <button class="btn-outline">Toggle</button>
56
+            </form>
57
+            <a class="btn-outline" href="{% url 'admin_frontend:profile_edit' p.pk %}">Edit</a>
58
+            <form method="post" action="{% url 'admin_frontend:profile_delete' p.pk %}" onsubmit="return confirm('Delete this profile?');">
59
+              {% csrf_token %}
60
+              <button class="btn-outline text-red-600">Delete</button>
61
+            </form>
62
+          </div>
63
+        </td>
64
+      </tr>
65
+      {% empty %}
66
+      <tr><td class="px-4 py-4 text-center text-gray-500" colspan="4">No profiles found.</td></tr>
67
+      {% endfor %}
68
+    </tbody>
69
+  </table>
70
+  {% include 'admin_frontend/_pagination.html' %}
71
+  </div>
72
+{% endblock %}

+ 97 - 0
admin_frontend/templatetags/public_urls.py

@@ -0,0 +1,97 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any, Dict, List, Optional, Tuple
4
+
5
+from django import template
6
+from django.urls import URLPattern, URLResolver, get_resolver, reverse, NoReverseMatch
7
+
8
+register = template.Library()
9
+
10
+
11
+def public_route(label: Optional[str] = None, order: int = 100, icon: Optional[str] = None):
12
+    """Decorator to mark a view as public-menu eligible.
13
+
14
+    label: Display label for menus (defaults to view name)
15
+    order: Sorting order (lower comes first)
16
+    icon:  Optional icon name (for your UI layer)
17
+    """
18
+
19
+    def _decorator(view):
20
+        meta = {
21
+            "label": label or getattr(view, "__name__", "").replace("_", " ").title(),
22
+            "order": order,
23
+            "icon": icon,
24
+        }
25
+        setattr(view, "_public_route", meta)
26
+        return view
27
+
28
+    return _decorator
29
+
30
+
31
+def _iter_patterns(resolver: URLResolver, ns_path: str = ""):
32
+    for entry in resolver.url_patterns:
33
+        if isinstance(entry, URLResolver):
34
+            new_ns = ns_path
35
+            if entry.namespace:
36
+                new_ns = f"{ns_path}:{entry.namespace}" if ns_path else entry.namespace
37
+            yield from _iter_patterns(entry, new_ns)
38
+        elif isinstance(entry, URLPattern):
39
+            full_name = entry.name
40
+            if full_name and ns_path:
41
+                full_name = f"{ns_path}:{full_name}"
42
+            yield entry, full_name
43
+
44
+
45
+def collect_public_urls(app_namespace: Optional[str] = None, public_only: bool = False) -> List[Dict[str, Any]]:
46
+    """Return a list of resolvable URLs (optionally limited to an app namespace).
47
+
48
+    Only includes patterns that can be reversed without args. If a view is decorated
49
+    with @public_route, its metadata is included; otherwise a sensible default is used.
50
+    """
51
+
52
+    resolver = get_resolver()
53
+    items: List[Dict[str, Any]] = []
54
+
55
+    for pattern, full_name in _iter_patterns(resolver):
56
+        if not full_name:
57
+            continue
58
+        if app_namespace and not full_name.startswith(app_namespace + ":"):
59
+            continue
60
+
61
+        # Try to reverse without args; skip otherwise
62
+        try:
63
+            url = reverse(full_name)
64
+        except NoReverseMatch:
65
+            continue
66
+
67
+        # Resolve view + metadata
68
+        view = pattern.callback
69
+        view_obj = getattr(view, "view_class", view)
70
+        meta = getattr(view_obj, "_public_route", None)
71
+        if public_only and not meta:
72
+            continue
73
+
74
+        label = meta.get("label") if meta else full_name.split(":")[-1].replace("_", " ").title()
75
+        order = meta.get("order") if meta else 1000
76
+        icon = meta.get("icon") if meta else None
77
+
78
+        items.append({
79
+            "name": full_name,
80
+            "url": url,
81
+            "label": label,
82
+            "order": order,
83
+            "icon": icon,
84
+        })
85
+
86
+    return sorted(items, key=lambda x: (x["order"], x["label"]))
87
+
88
+
89
+@register.simple_tag(takes_context=False)
90
+def app_menu(app_namespace: str, public_only: bool = True) -> List[Dict[str, Any]]:
91
+    """Template tag to fetch menu items for an app namespace.
92
+
93
+    Usage: {% load public_urls %} {% app_menu 'admin_frontend' as items %}
94
+    Then: {% for it in items %}<a href="{{ it.url }}">{{ it.label }}</a>{% endfor %}
95
+    """
96
+    return collect_public_urls(app_namespace=app_namespace, public_only=public_only)
97
+

+ 3 - 0
admin_frontend/tests.py

@@ -0,0 +1,3 @@
1
+from django.test import TestCase
2
+
3
+# Create your tests here.

+ 29 - 0
admin_frontend/urls.py

@@ -0,0 +1,29 @@
1
+from django.urls import path, include
2
+from django.contrib.auth.views import LogoutView
3
+from . import views
4
+
5
+app_name = "admin_frontend"
6
+
7
+urlpatterns = [
8
+    path("login/", views.SiteLoginView.as_view(template_name="admin_frontend/login.html"), name="login"),
9
+    path("logout/", LogoutView.as_view(next_page="/webadmin/login/"), name="logout"),
10
+
11
+    path("", views.dashboard, name="dashboard"),
12
+    path("profiles/", views.profiles_list, name="profiles_list"),
13
+    path("profiles/new/", views.profile_create, name="profile_create"),
14
+    path("profiles/<int:pk>/edit/", views.profile_edit, name="profile_edit"),
15
+    path("profiles/<int:pk>/delete/", views.profile_delete, name="profile_delete"),
16
+    path("opportunities/", views.opportunities_list, name="opportunities_list"),
17
+    path("opportunities/<int:pk>/edit/", views.opportunity_edit, name="opportunity_edit"),
18
+    path("opportunities/<int:pk>/delete/", views.opportunity_delete, name="opportunity_delete"),
19
+    path("introduction-requests/", views.introduction_requests_list, name="intro_requests_list"),
20
+    path("introduction-requests/new/", views.intro_request_create, name="intro_request_create"),
21
+    path("introduction-requests/<int:pk>/edit/", views.intro_request_edit, name="intro_request_edit"),
22
+    path("introduction-requests/<int:pk>/delete/", views.intro_request_delete, name="intro_request_delete"),
23
+
24
+    path("leaderboard/", views.leaderboard, name="leaderboard"),
25
+    path("profiles/<int:pk>/toggle-verify/", views.profile_toggle_verify, name="profile_toggle_verify"),
26
+    path("introduction-requests/<int:pk>/accept/", views.intro_request_accept, name="intro_request_accept"),
27
+
28
+    # Recycle Core UI moved to recycle_core app under /webadmin/recycle/
29
+]

+ 392 - 0
admin_frontend/views.py

@@ -0,0 +1,392 @@
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
+
9
+from api.models import Profile, Opportunity, IntroductionRequest
10
+from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
11
+import django_filters as filters
12
+from .forms import ProfileCreateForm, ProfileEditForm, OpportunityForm, IntroRequestForm
13
+from admin_frontend.templatetags.public_urls import public_route
14
+from api.services.matching import rank_users_for, rank_opportunities_for
15
+from api.services.benefit import build_leaderboard_queryset
16
+from .nav import _nav_items
17
+from orgs.models import OrganizationSite
18
+
19
+
20
+class SiteLoginView(LoginView):
21
+    """Login view that redirects users to their organization's Site domain.
22
+
23
+    After successful authentication, it looks up the user's organization via
24
+    `user.recycle_profile.organization`, maps it to a `Site` domain via
25
+    `OrganizationSite`, and returns an absolute URL to `/webadmin/` on that domain.
26
+    Falls back to the default behavior if mapping is missing.
27
+    """
28
+
29
+    def get_success_url(self):
30
+        url = super().get_success_url()
31
+        try:
32
+            user = self.request.user
33
+            org = getattr(getattr(user, "recycle_profile", None), "organization", None)
34
+            if org is None:
35
+                return url
36
+            mapping = OrganizationSite.objects.select_related("site").filter(organization=org).first()
37
+            if not mapping or not mapping.site:
38
+                return url
39
+
40
+            domain = mapping.site.domain
41
+            scheme = "https" if self.request.is_secure() else "http"
42
+            host_header = self.request.get_host() or ""
43
+            current_port = ""
44
+            # Preserve incoming port from Host header if present (e.g., 8024 in Docker)
45
+            if ":" in host_header:
46
+                try:
47
+                    current_port = host_header.rsplit(":", 1)[1]
48
+                except Exception:
49
+                    current_port = ""
50
+
51
+            if current_port and not (
52
+                (scheme == "http" and current_port == "80") or (scheme == "https" and current_port == "443")
53
+            ):
54
+                netloc = f"{domain}:{current_port}"
55
+            else:
56
+                netloc = domain
57
+
58
+            return f"{scheme}://{netloc}/webadmin/"
59
+        except Exception:
60
+            return url
61
+
62
+
63
+
64
+@staff_member_required
65
+@public_route(label="Dashboard", order=10, icon="home")
66
+def dashboard(request):
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
api/__init__.py


+ 3 - 0
api/admin.py

@@ -0,0 +1,3 @@
1
+from django.contrib import admin
2
+
3
+# Register your models here.

+ 9 - 0
api/apps.py

@@ -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

+ 1 - 0
api/management/__init__.py

@@ -0,0 +1 @@
1
+

+ 1 - 0
api/management/commands/__init__.py

@@ -0,0 +1 @@
1
+

+ 10 - 0
api/management/commands/create_superuser.py

@@ -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.'))

+ 162 - 0
api/management/commands/seed_demo.py

@@ -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
+        )

+ 48 - 0
api/migrations/0001_initial.py

@@ -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
+    ]

+ 20 - 0
api/migrations/0002_profile_tags.py

@@ -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
+    ]

+ 32 - 0
api/migrations/0003_benefitevent.py

@@ -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
+    ]

+ 19 - 0
api/migrations/0004_alter_benefitevent_created_at.py

@@ -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
api/migrations/__init__.py


+ 91 - 0
api/models.py

@@ -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}"

+ 46 - 0
api/serializers.py

@@ -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)

+ 87 - 0
api/services/benefit.py

@@ -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

+ 177 - 0
api/services/matching.py

@@ -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]

+ 9 - 0
api/signals.py

@@ -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)

+ 175 - 0
api/tests.py

@@ -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)

+ 62 - 0
api/urls.py

@@ -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
+]

+ 114 - 0
api/views.py

@@ -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})

+ 2 - 0
cms/__init__.py

@@ -0,0 +1,2 @@
1
+"""CMS app for content models like Post and PostCategory."""
2
+

+ 19 - 0
cms/admin.py

@@ -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")

+ 8 - 0
cms/apps.py

@@ -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
+

+ 45 - 0
cms/filters.py

@@ -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
+

+ 93 - 0
cms/forms.py

@@ -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

+ 1 - 0
cms/management/__init__.py

@@ -0,0 +1 @@
1
+# Package for CMS management commands

+ 1 - 0
cms/management/commands/__init__.py

@@ -0,0 +1 @@
1
+# Commands package

+ 83 - 0
cms/management/commands/seed_cms.py

@@ -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)

+ 89 - 0
cms/migrations/0001_initial.py

@@ -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
cms/migrations/__init__.py


+ 203 - 0
cms/models.py

@@ -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

+ 54 - 0
cms/templates/cms/categories_list.html

@@ -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 %}

+ 19 - 0
cms/templates/cms/category_form.html

@@ -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
+

+ 22 - 0
cms/templates/cms/post_form.html

@@ -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 %}

+ 74 - 0
cms/templates/cms/posts_list.html

@@ -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 %}

+ 48 - 0
cms/templates/cms/widgets/image_preview_input.html

@@ -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>

+ 1 - 0
cms/templatetags/__init__.py

@@ -0,0 +1 @@
1
+

+ 24 - 0
cms/templatetags/breadcrumbs.py

@@ -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}

+ 41 - 0
cms/tests.py

@@ -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)

+ 15 - 0
cms/urls.py

@@ -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
+

+ 308 - 0
cms/views.py

@@ -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")

+ 27 - 0
cms/widgets.py

@@ -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 = []

+ 23 - 0
docker-compose.yml

@@ -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
ecoloop/__init__.py


+ 16 - 0
ecoloop/asgi.py

@@ -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()

+ 28 - 0
ecoloop/context_processors.py

@@ -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}

+ 25 - 0
ecoloop/middleware.py

@@ -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
+

+ 281 - 0
ecoloop/settings.py

@@ -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/"

+ 49 - 0
ecoloop/urls.py

@@ -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)

+ 16 - 0
ecoloop/wsgi.py

@@ -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
frontend/__init__.py


+ 3 - 0
frontend/admin.py

@@ -0,0 +1,3 @@
1
+from django.contrib import admin
2
+
3
+# Register your models here.

+ 6 - 0
frontend/apps.py

@@ -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
frontend/migrations/__init__.py


+ 3 - 0
frontend/models.py

@@ -0,0 +1,3 @@
1
+from django.db import models
2
+
3
+# Create your models here.

+ 41 - 0
frontend/templates/frontend/base.html

@@ -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>

+ 14 - 0
frontend/templates/frontend/index.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 %}

+ 27 - 0
frontend/templates/frontend/leaderboard.html

@@ -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 %}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 51 - 0
frontend/templates/frontend/login.html


+ 16 - 0
frontend/templates/frontend/opportunities.html

@@ -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 %}

+ 21 - 0
frontend/templates/frontend/profiles.html

@@ -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 %}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 34 - 0
frontend/templates/frontend/signup.html


+ 3 - 0
frontend/tests.py

@@ -0,0 +1,3 @@
1
+from django.test import TestCase
2
+
3
+# Create your tests here.

+ 14 - 0
frontend/urls.py

@@ -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
+]

+ 31 - 0
frontend/views.py

@@ -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})

+ 22 - 0
manage.py

@@ -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()

+ 2 - 0
orgs/__init__.py

@@ -0,0 +1,2 @@
1
+default_app_config = "orgs.apps.OrgsConfig"
2
+

+ 0 - 0
orgs/admin.py


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio

tum/whitesports - Gogs: Simplico Git Service

Brak opisu

admin-bar.php 32KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269
  1. <?php
  2. /**
  3. * Toolbar API: Top-level Toolbar functionality
  4. *
  5. * @package WordPress
  6. * @subpackage Toolbar
  7. * @since 3.1.0
  8. */
  9. /**
  10. * Instantiate the admin bar object and set it up as a global for access elsewhere.
  11. *
  12. * UNHOOKING THIS FUNCTION WILL NOT PROPERLY REMOVE THE ADMIN BAR.
  13. * For that, use show_admin_bar(false) or the {@see 'show_admin_bar'} filter.
  14. *
  15. * @since 3.1.0
  16. * @access private
  17. *
  18. * @global WP_Admin_Bar $wp_admin_bar
  19. *
  20. * @return bool Whether the admin bar was successfully initialized.
  21. */
  22. function _wp_admin_bar_init() {
  23. global $wp_admin_bar;
  24. if ( ! is_admin_bar_showing() ) {
  25. return false;
  26. }
  27. /* Load the admin bar class code ready for instantiation */
  28. require_once ABSPATH . WPINC . '/class-wp-admin-bar.php';
  29. /* Instantiate the admin bar */
  30. /**
  31. * Filters the admin bar class to instantiate.
  32. *
  33. * @since 3.1.0
  34. *
  35. * @param string $wp_admin_bar_class Admin bar class to use. Default 'WP_Admin_Bar'.
  36. */
  37. $admin_bar_class = apply_filters( 'wp_admin_bar_class', 'WP_Admin_Bar' );
  38. if ( class_exists( $admin_bar_class ) ) {
  39. $wp_admin_bar = new $admin_bar_class;
  40. } else {
  41. return false;
  42. }
  43. $wp_admin_bar->initialize();
  44. $wp_admin_bar->add_menus();
  45. return true;
  46. }
  47. /**
  48. * Renders the admin bar to the page based on the $wp_admin_bar->menu member var.
  49. *
  50. * This is called very early on the {@see 'wp_body_open'} action so that it will render
  51. * before anything else being added to the page body.
  52. *
  53. * For backward compatibility with themes not using the 'wp_body_open' action,
  54. * the function is also called late on {@see 'wp_footer'}.
  55. *
  56. * It includes the {@see 'admin_bar_menu'} action which should be used to hook in and
  57. * add new menus to the admin bar. That way you can be sure that you are adding at most
  58. * optimal point, right before the admin bar is rendered. This also gives you access to
  59. * the `$post` global, among others.
  60. *
  61. * @since 3.1.0
  62. * @since 5.4.0 Called on 'wp_body_open' action first, with 'wp_footer' as a fallback.
  63. *
  64. * @global WP_Admin_Bar $wp_admin_bar
  65. */
  66. function wp_admin_bar_render() {
  67. global $wp_admin_bar;
  68. static $rendered = false;
  69. if ( $rendered ) {
  70. return;
  71. }
  72. if ( ! is_admin_bar_showing() || ! is_object( $wp_admin_bar ) ) {
  73. return;
  74. }
  75. /**
  76. * Load all necessary admin bar items.
  77. *
  78. * This is the hook used to add, remove, or manipulate admin bar items.
  79. *
  80. * @since 3.1.0
  81. *
  82. * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar instance, passed by reference
  83. */
  84. do_action_ref_array( 'admin_bar_menu', array( &$wp_admin_bar ) );
  85. /**
  86. * Fires before the admin bar is rendered.
  87. *
  88. * @since 3.1.0
  89. */
  90. do_action( 'wp_before_admin_bar_render' );
  91. $wp_admin_bar->render();
  92. /**
  93. * Fires after the admin bar is rendered.
  94. *
  95. * @since 3.1.0
  96. */
  97. do_action( 'wp_after_admin_bar_render' );
  98. $rendered = true;
  99. }
  100. /**
  101. * Add the WordPress logo menu.
  102. *
  103. * @since 3.3.0
  104. *
  105. * @param WP_Admin_Bar $wp_admin_bar
  106. */
  107. function wp_admin_bar_wp_menu( $wp_admin_bar ) {
  108. if ( current_user_can( 'read' ) ) {
  109. $about_url = self_admin_url( 'about.php' );
  110. } elseif ( is_multisite() ) {
  111. $about_url = get_dashboard_url( get_current_user_id(), 'about.php' );
  112. } else {
  113. $about_url = false;
  114. }
  115. $wp_logo_menu_args = array(
  116. 'id' => 'wp-logo',
  117. 'title' => '<span class="ab-icon" aria-hidden="true"></span><span class="screen-reader-text">' . __( 'About WordPress' ) . '</span>',
  118. 'href' => $about_url,
  119. );
  120. // Set tabindex="0" to make sub menus accessible when no URL is available.
  121. if ( ! $about_url ) {
  122. $wp_logo_menu_args['meta'] = array(
  123. 'tabindex' => 0,
  124. );
  125. }
  126. $wp_admin_bar->add_node( $wp_logo_menu_args );
  127. if ( $about_url ) {
  128. // Add "About WordPress" link.
  129. $wp_admin_bar->add_node(
  130. array(
  131. 'parent' => 'wp-logo',
  132. 'id' => 'about',
  133. 'title' => __( 'About WordPress' ),
  134. 'href' => $about_url,
  135. )
  136. );
  137. }
  138. // Add WordPress.org link.
  139. $wp_admin_bar->add_node(
  140. array(
  141. 'parent' => 'wp-logo-external',
  142. 'id' => 'wporg',
  143. 'title' => __( 'WordPress.org' ),
  144. 'href' => __( 'https://wordpress.org/' ),
  145. )
  146. );
  147. // Add documentation link.
  148. $wp_admin_bar->add_node(
  149. array(
  150. 'parent' => 'wp-logo-external',
  151. 'id' => 'documentation',
  152. 'title' => __( 'Documentation' ),
  153. 'href' => __( 'https://wordpress.org/support/' ),
  154. )
  155. );
  156. // Add forums link.
  157. $wp_admin_bar->add_node(
  158. array(
  159. 'parent' => 'wp-logo-external',
  160. 'id' => 'support-forums',
  161. 'title' => __( 'Support' ),
  162. 'href' => __( 'https://wordpress.org/support/forums/' ),
  163. )
  164. );
  165. // Add feedback link.
  166. $wp_admin_bar->add_node(
  167. array(
  168. 'parent' => 'wp-logo-external',
  169. 'id' => 'feedback',
  170. 'title' => __( 'Feedback' ),
  171. 'href' => __( 'https://wordpress.org/support/forum/requests-and-feedback' ),
  172. )
  173. );
  174. }
  175. /**
  176. * Add the sidebar toggle button.
  177. *
  178. * @since 3.8.0
  179. *
  180. * @param WP_Admin_Bar $wp_admin_bar
  181. */
  182. function wp_admin_bar_sidebar_toggle( $wp_admin_bar ) {
  183. if ( is_admin() ) {
  184. $wp_admin_bar->add_node(
  185. array(
  186. 'id' => 'menu-toggle',
  187. 'title' => '<span class="ab-icon" aria-hidden="true"></span><span class="screen-reader-text">' . __( 'Menu' ) . '</span>',
  188. 'href' => '#',
  189. )
  190. );
  191. }
  192. }
  193. /**
  194. * Add the "My Account" item.
  195. *
  196. * @since 3.3.0
  197. *
  198. * @param WP_Admin_Bar $wp_admin_bar
  199. */
  200. function wp_admin_bar_my_account_item( $wp_admin_bar ) {
  201. $user_id = get_current_user_id();
  202. $current_user = wp_get_current_user();
  203. if ( ! $user_id ) {
  204. return;
  205. }
  206. if ( current_user_can( 'read' ) ) {
  207. $profile_url = get_edit_profile_url( $user_id );
  208. } elseif ( is_multisite() ) {
  209. $profile_url = get_dashboard_url( $user_id, 'profile.php' );
  210. } else {
  211. $profile_url = false;
  212. }
  213. $avatar = get_avatar( $user_id, 26 );
  214. /* translators: %s: Current user's display name. */
  215. $howdy = sprintf( __( 'Howdy, %s' ), '<span class="display-name">' . $current_user->display_name . '</span>' );
  216. $class = empty( $avatar ) ? '' : 'with-avatar';
  217. $wp_admin_bar->add_node(
  218. array(
  219. 'id' => 'my-account',
  220. 'parent' => 'top-secondary',
  221. 'title' => $howdy . $avatar,
  222. 'href' => $profile_url,
  223. 'meta' => array(
  224. 'class' => $class,
  225. ),
  226. )
  227. );
  228. }
  229. /**
  230. * Add the "My Account" submenu items.
  231. *
  232. * @since 3.1.0
  233. *
  234. * @param WP_Admin_Bar $wp_admin_bar
  235. */
  236. function wp_admin_bar_my_account_menu( $wp_admin_bar ) {
  237. $user_id = get_current_user_id();
  238. $current_user = wp_get_current_user();
  239. if ( ! $user_id ) {
  240. return;
  241. }
  242. if ( current_user_can( 'read' ) ) {
  243. $profile_url = get_edit_profile_url( $user_id );
  244. } elseif ( is_multisite() ) {
  245. $profile_url = get_dashboard_url( $user_id, 'profile.php' );
  246. } else {
  247. $profile_url = false;
  248. }
  249. $wp_admin_bar->add_group(
  250. array(
  251. 'parent' => 'my-account',
  252. 'id' => 'user-actions',
  253. )
  254. );
  255. $user_info = get_avatar( $user_id, 64 );
  256. $user_info .= "<span class='display-name'>{$current_user->display_name}</span>";
  257. if ( $current_user->display_name !== $current_user->user_login ) {
  258. $user_info .= "<span class='username'>{$current_user->user_login}</span>";
  259. }
  260. $wp_admin_bar->add_node(
  261. array(
  262. 'parent' => 'user-actions',
  263. 'id' => 'user-info',
  264. 'title' => $user_info,
  265. 'href' => $profile_url,
  266. 'meta' => array(
  267. 'tabindex' => -1,
  268. ),
  269. )
  270. );
  271. if ( false !== $profile_url ) {
  272. $wp_admin_bar->add_node(
  273. array(
  274. 'parent' => 'user-actions',
  275. 'id' => 'edit-profile',
  276. 'title' => __( 'Edit Profile' ),
  277. 'href' => $profile_url,
  278. )
  279. );
  280. }
  281. $wp_admin_bar->add_node(
  282. array(
  283. 'parent' => 'user-actions',
  284. 'id' => 'logout',
  285. 'title' => __( 'Log Out' ),
  286. 'href' => wp_logout_url(),
  287. )
  288. );
  289. }
  290. /**
  291. * Add the "Site Name" menu.
  292. *
  293. * @since 3.3.0
  294. *
  295. * @param WP_Admin_Bar $wp_admin_bar
  296. */
  297. function wp_admin_bar_site_menu( $wp_admin_bar ) {
  298. // Don't show for logged out users.
  299. if ( ! is_user_logged_in() ) {
  300. return;
  301. }
  302. // Show only when the user is a member of this site, or they're a super admin.
  303. if ( ! is_user_member_of_blog() && ! current_user_can( 'manage_network' ) ) {
  304. return;
  305. }
  306. $blogname = get_bloginfo( 'name' );
  307. if ( ! $blogname ) {
  308. $blogname = preg_replace( '#^(https?://)?(www.)?#', '', get_home_url() );
  309. }
  310. if ( is_network_admin() ) {
  311. /* translators: %s: Site title. */
  312. $blogname = sprintf( __( 'Network Admin: %s' ), esc_html( get_network()->site_name ) );
  313. } elseif ( is_user_admin() ) {
  314. /* translators: %s: Site title. */
  315. $blogname = sprintf( __( 'User Dashboard: %s' ), esc_html( get_network()->site_name ) );
  316. }
  317. $title = wp_html_excerpt( $blogname, 40, '&hellip;' );
  318. $wp_admin_bar->add_node(
  319. array(
  320. 'id' => 'site-name',
  321. 'title' => $title,
  322. 'href' => ( is_admin() || ! current_user_can( 'read' ) ) ? home_url( '/' ) : admin_url(),
  323. )
  324. );
  325. // Create submenu items.
  326. if ( is_admin() ) {
  327. // Add an option to visit the site.
  328. $wp_admin_bar->add_node(
  329. array(
  330. 'parent' => 'site-name',
  331. 'id' => 'view-site',
  332. 'title' => __( 'Visit Site' ),
  333. 'href' => home_url( '/' ),
  334. )
  335. );
  336. if ( is_blog_admin() && is_multisite() && current_user_can( 'manage_sites' ) ) {
  337. $wp_admin_bar->add_node(
  338. array(
  339. 'parent' => 'site-name',
  340. 'id' => 'edit-site',
  341. 'title' => __( 'Edit Site' ),
  342. 'href' => network_admin_url( 'site-info.php?id=' . get_current_blog_id() ),
  343. )
  344. );
  345. }
  346. } elseif ( current_user_can( 'read' ) ) {
  347. // We're on the front end, link to the Dashboard.
  348. $wp_admin_bar->add_node(
  349. array(
  350. 'parent' => 'site-name',
  351. 'id' => 'dashboard',
  352. 'title' => __( 'Dashboard' ),
  353. 'href' => admin_url(),
  354. )
  355. );
  356. // Add the appearance submenu items.
  357. wp_admin_bar_appearance_menu( $wp_admin_bar );
  358. }
  359. }
  360. /**
  361. * Adds the "Customize" link to the Toolbar.
  362. *
  363. * @since 4.3.0
  364. *
  365. * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar instance.
  366. * @global WP_Customize_Manager $wp_customize
  367. */
  368. function wp_admin_bar_customize_menu( $wp_admin_bar ) {
  369. global $wp_customize;
  370. // Don't show for users who can't access the customizer or when in the admin.
  371. if ( ! current_user_can( 'customize' ) || is_admin() ) {
  372. return;
  373. }
  374. // Don't show if the user cannot edit a given customize_changeset post currently being previewed.
  375. if ( is_customize_preview() && $wp_customize->changeset_post_id()
  376. && ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() )
  377. ) {
  378. return;
  379. }
  380. $current_url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
  381. if ( is_customize_preview() && $wp_customize->changeset_uuid() ) {
  382. $current_url = remove_query_arg( 'customize_changeset_uuid', $current_url );
  383. }
  384. $customize_url = add_query_arg( 'url', urlencode( $current_url ), wp_customize_url() );
  385. if ( is_customize_preview() ) {
  386. $customize_url = add_query_arg( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ), $customize_url );
  387. }
  388. $wp_admin_bar->add_node(
  389. array(
  390. 'id' => 'customize',
  391. 'title' => __( 'Customize' ),
  392. 'href' => $customize_url,
  393. 'meta' => array(
  394. 'class' => 'hide-if-no-customize',
  395. ),
  396. )
  397. );
  398. add_action( 'wp_before_admin_bar_render', 'wp_customize_support_script' );
  399. }
  400. /**
  401. * Add the "My Sites/[Site Name]" menu and all submenus.
  402. *
  403. * @since 3.1.0
  404. *
  405. * @param WP_Admin_Bar $wp_admin_bar
  406. */
  407. function wp_admin_bar_my_sites_menu( $wp_admin_bar ) {
  408. // Don't show for logged out users or single site mode.
  409. if ( ! is_user_logged_in() || ! is_multisite() ) {
  410. return;
  411. }
  412. // Show only when the user has at least one site, or they're a super admin.
  413. if ( count( $wp_admin_bar->user->blogs ) < 1 && ! current_user_can( 'manage_network' ) ) {
  414. return;
  415. }
  416. if ( $wp_admin_bar->user->active_blog ) {
  417. $my_sites_url = get_admin_url( $wp_admin_bar->user->active_blog->blog_id, 'my-sites.php' );
  418. } else {
  419. $my_sites_url = admin_url( 'my-sites.php' );
  420. }
  421. $wp_admin_bar->add_node(
  422. array(
  423. 'id' => 'my-sites',
  424. 'title' => __( 'My Sites' ),
  425. 'href' => $my_sites_url,
  426. )
  427. );
  428. if ( current_user_can( 'manage_network' ) ) {
  429. $wp_admin_bar->add_group(
  430. array(
  431. 'parent' => 'my-sites',
  432. 'id' => 'my-sites-super-admin',
  433. )
  434. );
  435. $wp_admin_bar->add_node(
  436. array(
  437. 'parent' => 'my-sites-super-admin',
  438. 'id' => 'network-admin',
  439. 'title' => __( 'Network Admin' ),
  440. 'href' => network_admin_url(),
  441. )
  442. );
  443. $wp_admin_bar->add_node(
  444. array(
  445. 'parent' => 'network-admin',
  446. 'id' => 'network-admin-d',
  447. 'title' => __( 'Dashboard' ),
  448. 'href' => network_admin_url(),
  449. )
  450. );
  451. if ( current_user_can( 'manage_sites' ) ) {
  452. $wp_admin_bar->add_node(
  453. array(
  454. 'parent' => 'network-admin',
  455. 'id' => 'network-admin-s',
  456. 'title' => __( 'Sites' ),
  457. 'href' => network_admin_url( 'sites.php' ),
  458. )
  459. );
  460. }
  461. if ( current_user_can( 'manage_network_users' ) ) {
  462. $wp_admin_bar->add_node(
  463. array(
  464. 'parent' => 'network-admin',
  465. 'id' => 'network-admin-u',
  466. 'title' => __( 'Users' ),
  467. 'href' => network_admin_url( 'users.php' ),
  468. )
  469. );
  470. }
  471. if ( current_user_can( 'manage_network_themes' ) ) {
  472. $wp_admin_bar->add_node(
  473. array(
  474. 'parent' => 'network-admin',
  475. 'id' => 'network-admin-t',
  476. 'title' => __( 'Themes' ),
  477. 'href' => network_admin_url( 'themes.php' ),
  478. )
  479. );
  480. }
  481. if ( current_user_can( 'manage_network_plugins' ) ) {
  482. $wp_admin_bar->add_node(
  483. array(
  484. 'parent' => 'network-admin',
  485. 'id' => 'network-admin-p',
  486. 'title' => __( 'Plugins' ),
  487. 'href' => network_admin_url( 'plugins.php' ),
  488. )
  489. );
  490. }
  491. if ( current_user_can( 'manage_network_options' ) ) {
  492. $wp_admin_bar->add_node(
  493. array(
  494. 'parent' => 'network-admin',
  495. 'id' => 'network-admin-o',
  496. 'title' => __( 'Settings' ),
  497. 'href' => network_admin_url( 'settings.php' ),
  498. )
  499. );
  500. }
  501. }
  502. // Add site links.
  503. $wp_admin_bar->add_group(
  504. array(
  505. 'parent' => 'my-sites',
  506. 'id' => 'my-sites-list',
  507. 'meta' => array(
  508. 'class' => current_user_can( 'manage_network' ) ? 'ab-sub-secondary' : '',
  509. ),
  510. )
  511. );
  512. foreach ( (array) $wp_admin_bar->user->blogs as $blog ) {
  513. switch_to_blog( $blog->userblog_id );
  514. if ( has_site_icon() ) {
  515. $blavatar = sprintf(
  516. '<img class="blavatar" src="%s" srcset="%s 2x" alt="" width="16" height="16" />',
  517. esc_url( get_site_icon_url( 16 ) ),
  518. esc_url( get_site_icon_url( 32 ) )
  519. );
  520. } else {
  521. $blavatar = '<div class="blavatar"></div>';
  522. }
  523. $blogname = $blog->blogname;
  524. if ( ! $blogname ) {
  525. $blogname = preg_replace( '#^(https?://)?(www.)?#', '', get_home_url() );
  526. }
  527. $menu_id = 'blog-' . $blog->userblog_id;
  528. if ( current_user_can( 'read' ) ) {
  529. $wp_admin_bar->add_node(
  530. array(
  531. 'parent' => 'my-sites-list',
  532. 'id' => $menu_id,
  533. 'title' => $blavatar . $blogname,
  534. 'href' => admin_url(),
  535. )
  536. );
  537. $wp_admin_bar->add_node(
  538. array(
  539. 'parent' => $menu_id,
  540. 'id' => $menu_id . '-d',
  541. 'title' => __( 'Dashboard' ),
  542. 'href' => admin_url(),
  543. )
  544. );
  545. } else {
  546. $wp_admin_bar->add_node(
  547. array(
  548. 'parent' => 'my-sites-list',
  549. 'id' => $menu_id,
  550. 'title' => $blavatar . $blogname,
  551. 'href' => home_url(),
  552. )
  553. );
  554. }
  555. if ( current_user_can( get_post_type_object( 'post' )->cap->create_posts ) ) {
  556. $wp_admin_bar->add_node(
  557. array(
  558. 'parent' => $menu_id,
  559. 'id' => $menu_id . '-n',
  560. 'title' => get_post_type_object( 'post' )->labels->new_item,
  561. 'href' => admin_url( 'post-new.php' ),
  562. )
  563. );
  564. }
  565. if ( current_user_can( 'edit_posts' ) ) {
  566. $wp_admin_bar->add_node(
  567. array(
  568. 'parent' => $menu_id,
  569. 'id' => $menu_id . '-c',
  570. 'title' => __( 'Manage Comments' ),
  571. 'href' => admin_url( 'edit-comments.php' ),
  572. )
  573. );
  574. }
  575. $wp_admin_bar->add_node(
  576. array(
  577. 'parent' => $menu_id,
  578. 'id' => $menu_id . '-v',
  579. 'title' => __( 'Visit Site' ),
  580. 'href' => home_url( '/' ),
  581. )
  582. );
  583. restore_current_blog();
  584. }
  585. }
  586. /**
  587. * Provide a shortlink.
  588. *
  589. * @since 3.1.0
  590. *
  591. * @param WP_Admin_Bar $wp_admin_bar
  592. */
  593. function wp_admin_bar_shortlink_menu( $wp_admin_bar ) {
  594. $short = wp_get_shortlink( 0, 'query' );
  595. $id = 'get-shortlink';
  596. if ( empty( $short ) ) {
  597. return;
  598. }
  599. $html = '<input class="shortlink-input" type="text" readonly="readonly" value="' . esc_attr( $short ) . '" />';
  600. $wp_admin_bar->add_node(
  601. array(
  602. 'id' => $id,
  603. 'title' => __( 'Shortlink' ),
  604. 'href' => $short,
  605. 'meta' => array( 'html' => $html ),
  606. )
  607. );
  608. }
  609. /**
  610. * Provide an edit link for posts and terms.
  611. *
  612. * @since 3.1.0
  613. * @since 5.5.0 Added a "View Post" link on Comments screen for a single post.
  614. *
  615. * @global WP_Term $tag
  616. * @global WP_Query $wp_the_query WordPress Query object.
  617. * @global int $user_id The ID of the user being edited. Not to be confused with the
  618. * global $user_ID, which contains the ID of the current user.
  619. * @global int $post_id The ID of the post when editing comments for a single post.
  620. *
  621. * @param WP_Admin_Bar $wp_admin_bar
  622. */
  623. function wp_admin_bar_edit_menu( $wp_admin_bar ) {
  624. global $tag, $wp_the_query, $user_id, $post_id;
  625. if ( is_admin() ) {
  626. $current_screen = get_current_screen();
  627. $post = get_post();
  628. $post_type_object = null;
  629. if ( 'post' === $current_screen->base ) {
  630. $post_type_object = get_post_type_object( $post->post_type );
  631. } elseif ( 'edit' === $current_screen->base ) {
  632. $post_type_object = get_post_type_object( $current_screen->post_type );
  633. } elseif ( 'edit-comments' === $current_screen->base && $post_id ) {
  634. $post = get_post( $post_id );
  635. if ( $post ) {
  636. $post_type_object = get_post_type_object( $post->post_type );
  637. }
  638. }
  639. if ( ( 'post' === $current_screen->base || 'edit-comments' === $current_screen->base )
  640. && 'add' !== $current_screen->action
  641. && ( $post_type_object )
  642. && current_user_can( 'read_post', $post->ID )
  643. && ( $post_type_object->public )
  644. && ( $post_type_object->show_in_admin_bar ) ) {
  645. if ( 'draft' === $post->post_status ) {
  646. $preview_link = get_preview_post_link( $post );
  647. $wp_admin_bar->add_node(
  648. array(
  649. 'id' => 'preview',
  650. 'title' => $post_type_object->labels->view_item,
  651. 'href' => esc_url( $preview_link ),
  652. 'meta' => array( 'target' => 'wp-preview-' . $post->ID ),
  653. )
  654. );
  655. } else {
  656. $wp_admin_bar->add_node(
  657. array(
  658. 'id' => 'view',
  659. 'title' => $post_type_object->labels->view_item,
  660. 'href' => get_permalink( $post->ID ),
  661. )
  662. );
  663. }
  664. } elseif ( 'edit' === $current_screen->base
  665. && ( $post_type_object )
  666. && ( $post_type_object->public )
  667. && ( $post_type_object->show_in_admin_bar )
  668. && ( get_post_type_archive_link( $post_type_object->name ) )
  669. && ! ( 'post' === $post_type_object->name && 'posts' === get_option( 'show_on_front' ) ) ) {
  670. $wp_admin_bar->add_node(
  671. array(
  672. 'id' => 'archive',
  673. 'title' => $post_type_object->labels->view_items,
  674. 'href' => get_post_type_archive_link( $current_screen->post_type ),
  675. )
  676. );
  677. } elseif ( 'term' === $current_screen->base && isset( $tag ) && is_object( $tag ) && ! is_wp_error( $tag ) ) {
  678. $tax = get_taxonomy( $tag->taxonomy );
  679. if ( is_taxonomy_viewable( $tax ) ) {
  680. $wp_admin_bar->add_node(
  681. array(
  682. 'id' => 'view',
  683. 'title' => $tax->labels->view_item,
  684. 'href' => get_term_link( $tag ),
  685. )
  686. );
  687. }
  688. } elseif ( 'user-edit' === $current_screen->base && isset( $user_id ) ) {
  689. $user_object = get_userdata( $user_id );
  690. $view_link = get_author_posts_url( $user_object->ID );
  691. if ( $user_object->exists() && $view_link ) {
  692. $wp_admin_bar->add_node(
  693. array(
  694. 'id' => 'view',
  695. 'title' => __( 'View User' ),
  696. 'href' => $view_link,
  697. )
  698. );
  699. }
  700. }
  701. } else {
  702. $current_object = $wp_the_query->get_queried_object();
  703. if ( empty( $current_object ) ) {
  704. return;
  705. }
  706. if ( ! empty( $current_object->post_type ) ) {
  707. $post_type_object = get_post_type_object( $current_object->post_type );
  708. $edit_post_link = get_edit_post_link( $current_object->ID );
  709. if ( $post_type_object
  710. && $edit_post_link
  711. && current_user_can( 'edit_post', $current_object->ID )
  712. && $post_type_object->show_in_admin_bar ) {
  713. $wp_admin_bar->add_node(
  714. array(
  715. 'id' => 'edit',
  716. 'title' => $post_type_object->labels->edit_item,
  717. 'href' => $edit_post_link,
  718. )
  719. );
  720. }
  721. } elseif ( ! empty( $current_object->taxonomy ) ) {
  722. $tax = get_taxonomy( $current_object->taxonomy );
  723. $edit_term_link = get_edit_term_link( $current_object->term_id, $current_object->taxonomy );
  724. if ( $tax && $edit_term_link && current_user_can( 'edit_term', $current_object->term_id ) ) {
  725. $wp_admin_bar->add_node(
  726. array(
  727. 'id' => 'edit',
  728. 'title' => $tax->labels->edit_item,
  729. 'href' => $edit_term_link,
  730. )
  731. );
  732. }
  733. } elseif ( is_a( $current_object, 'WP_User' ) && current_user_can( 'edit_user', $current_object->ID ) ) {
  734. $edit_user_link = get_edit_user_link( $current_object->ID );
  735. if ( $edit_user_link ) {
  736. $wp_admin_bar->add_node(
  737. array(
  738. 'id' => 'edit',
  739. 'title' => __( 'Edit User' ),
  740. 'href' => $edit_user_link,
  741. )
  742. );
  743. }
  744. }
  745. }
  746. }
  747. /**
  748. * Add "Add New" menu.
  749. *
  750. * @since 3.1.0
  751. *
  752. * @param WP_Admin_Bar $wp_admin_bar
  753. */
  754. function wp_admin_bar_new_content_menu( $wp_admin_bar ) {
  755. $actions = array();
  756. $cpts = (array) get_post_types( array( 'show_in_admin_bar' => true ), 'objects' );
  757. if ( isset( $cpts['post'] ) && current_user_can( $cpts['post']->cap->create_posts ) ) {
  758. $actions['post-new.php'] = array( $cpts['post']->labels->name_admin_bar, 'new-post' );
  759. }
  760. if ( isset( $cpts['attachment'] ) && current_user_can( 'upload_files' ) ) {
  761. $actions['media-new.php'] = array( $cpts['attachment']->labels->name_admin_bar, 'new-media' );
  762. }
  763. if ( current_user_can( 'manage_links' ) ) {
  764. $actions['link-add.php'] = array( _x( 'Link', 'add new from admin bar' ), 'new-link' );
  765. }
  766. if ( isset( $cpts['page'] ) && current_user_can( $cpts['page']->cap->create_posts ) ) {
  767. $actions['post-new.php?post_type=page'] = array( $cpts['page']->labels->name_admin_bar, 'new-page' );
  768. }
  769. unset( $cpts['post'], $cpts['page'], $cpts['attachment'] );
  770. // Add any additional custom post types.
  771. foreach ( $cpts as $cpt ) {
  772. if ( ! current_user_can( $cpt->cap->create_posts ) ) {
  773. continue;
  774. }
  775. $key = 'post-new.php?post_type=' . $cpt->name;
  776. $actions[ $key ] = array( $cpt->labels->name_admin_bar, 'new-' . $cpt->name );
  777. }
  778. // Avoid clash with parent node and a 'content' post type.
  779. if ( isset( $actions['post-new.php?post_type=content'] ) ) {
  780. $actions['post-new.php?post_type=content'][1] = 'add-new-content';
  781. }
  782. if ( current_user_can( 'create_users' ) || ( is_multisite() && current_user_can( 'promote_users' ) ) ) {
  783. $actions['user-new.php'] = array( _x( 'User', 'add new from admin bar' ), 'new-user' );
  784. }
  785. if ( ! $actions ) {
  786. return;
  787. }
  788. $title = '<span class="ab-icon" aria-hidden="true"></span><span class="ab-label">' . _x( 'New', 'admin bar menu group label' ) . '</span>';
  789. $wp_admin_bar->add_node(
  790. array(
  791. 'id' => 'new-content',
  792. 'title' => $title,
  793. 'href' => admin_url( current( array_keys( $actions ) ) ),
  794. )
  795. );
  796. foreach ( $actions as $link => $action ) {
  797. list( $title, $id ) = $action;
  798. $wp_admin_bar->add_node(
  799. array(
  800. 'parent' => 'new-content',
  801. 'id' => $id,
  802. 'title' => $title,
  803. 'href' => admin_url( $link ),
  804. )
  805. );
  806. }
  807. }
  808. /**
  809. * Add edit comments link with awaiting moderation count bubble.
  810. *
  811. * @since 3.1.0
  812. *
  813. * @param WP_Admin_Bar $wp_admin_bar
  814. */
  815. function wp_admin_bar_comments_menu( $wp_admin_bar ) {
  816. if ( ! current_user_can( 'edit_posts' ) ) {
  817. return;
  818. }
  819. $awaiting_mod = wp_count_comments();
  820. $awaiting_mod = $awaiting_mod->moderated;
  821. $awaiting_text = sprintf(
  822. /* translators: %s: Number of comments. */
  823. _n( '%s Comment in moderation', '%s Comments in moderation', $awaiting_mod ),
  824. number_format_i18n( $awaiting_mod )
  825. );
  826. $icon = '<span class="ab-icon" aria-hidden="true"></span>';
  827. $title = '<span class="ab-label awaiting-mod pending-count count-' . $awaiting_mod . '" aria-hidden="true">' . number_format_i18n( $awaiting_mod ) . '</span>';
  828. $title .= '<span class="screen-reader-text comments-in-moderation-text">' . $awaiting_text . '</span>';
  829. $wp_admin_bar->add_node(
  830. array(
  831. 'id' => 'comments',
  832. 'title' => $icon . $title,
  833. 'href' => admin_url( 'edit-comments.php' ),
  834. )
  835. );
  836. }
  837. /**
  838. * Add appearance submenu items to the "Site Name" menu.
  839. *
  840. * @since 3.1.0
  841. *
  842. * @param WP_Admin_Bar $wp_admin_bar
  843. */
  844. function wp_admin_bar_appearance_menu( $wp_admin_bar ) {
  845. $wp_admin_bar->add_group(
  846. array(
  847. 'parent' => 'site-name',
  848. 'id' => 'appearance',
  849. )
  850. );
  851. if ( current_user_can( 'switch_themes' ) ) {
  852. $wp_admin_bar->add_node(
  853. array(
  854. 'parent' => 'appearance',
  855. 'id' => 'themes',
  856. 'title' => __( 'Themes' ),
  857. 'href' => admin_url( 'themes.php' ),
  858. )
  859. );
  860. }
  861. if ( ! current_user_can( 'edit_theme_options' ) ) {
  862. return;
  863. }
  864. if ( current_theme_supports( 'widgets' ) ) {
  865. $wp_admin_bar->add_node(
  866. array(
  867. 'parent' => 'appearance',
  868. 'id' => 'widgets',
  869. 'title' => __( 'Widgets' ),
  870. 'href' => admin_url( 'widgets.php' ),
  871. )
  872. );
  873. }
  874. if ( current_theme_supports( 'menus' ) || current_theme_supports( 'widgets' ) ) {
  875. $wp_admin_bar->add_node(
  876. array(
  877. 'parent' => 'appearance',
  878. 'id' => 'menus',
  879. 'title' => __( 'Menus' ),
  880. 'href' => admin_url( 'nav-menus.php' ),
  881. )
  882. );
  883. }
  884. if ( current_theme_supports( 'custom-background' ) ) {
  885. $wp_admin_bar->add_node(
  886. array(
  887. 'parent' => 'appearance',
  888. 'id' => 'background',
  889. 'title' => __( 'Background' ),
  890. 'href' => admin_url( 'themes.php?page=custom-background' ),
  891. 'meta' => array(
  892. 'class' => 'hide-if-customize',
  893. ),
  894. )
  895. );
  896. }
  897. if ( current_theme_supports( 'custom-header' ) ) {
  898. $wp_admin_bar->add_node(
  899. array(
  900. 'parent' => 'appearance',
  901. 'id' => 'header',
  902. 'title' => __( 'Header' ),
  903. 'href' => admin_url( 'themes.php?page=custom-header' ),
  904. 'meta' => array(
  905. 'class' => 'hide-if-customize',
  906. ),
  907. )
  908. );
  909. }
  910. }
  911. /**
  912. * Provide an update link if theme/plugin/core updates are available.
  913. *
  914. * @since 3.1.0
  915. *
  916. * @param WP_Admin_Bar $wp_admin_bar
  917. */
  918. function wp_admin_bar_updates_menu( $wp_admin_bar ) {
  919. $update_data = wp_get_update_data();
  920. if ( ! $update_data['counts']['total'] ) {
  921. return;
  922. }
  923. $updates_text = sprintf(
  924. /* translators: %s: Total number of updates available. */
  925. _n( '%s update available', '%s updates available', $update_data['counts']['total'] ),
  926. number_format_i18n( $update_data['counts']['total'] )
  927. );
  928. $icon = '<span class="ab-icon" aria-hidden="true"></span>';
  929. $title = '<span class="ab-label" aria-hidden="true">' . number_format_i18n( $update_data['counts']['total'] ) . '</span>';
  930. $title .= '<span class="screen-reader-text updates-available-text">' . $updates_text . '</span>';
  931. $wp_admin_bar->add_node(
  932. array(
  933. 'id' => 'updates',
  934. 'title' => $icon . $title,
  935. 'href' => network_admin_url( 'update-core.php' ),
  936. )
  937. );
  938. }
  939. /**
  940. * Add search form.
  941. *
  942. * @since 3.3.0
  943. *
  944. * @param WP_Admin_Bar $wp_admin_bar
  945. */
  946. function wp_admin_bar_search_menu( $wp_admin_bar ) {
  947. if ( is_admin() ) {
  948. return;
  949. }
  950. $form = '<form action="' . esc_url( home_url( '/' ) ) . '" method="get" id="adminbarsearch">';
  951. $form .= '<input class="adminbar-input" name="s" id="adminbar-search" type="text" value="" maxlength="150" />';
  952. $form .= '<label for="adminbar-search" class="screen-reader-text">' . __( 'Search' ) . '</label>';
  953. $form .= '<input type="submit" class="adminbar-button" value="' . __( 'Search' ) . '" />';
  954. $form .= '</form>';
  955. $wp_admin_bar->add_node(
  956. array(
  957. 'parent' => 'top-secondary',
  958. 'id' => 'search',
  959. 'title' => $form,
  960. 'meta' => array(
  961. 'class' => 'admin-bar-search',
  962. 'tabindex' => -1,
  963. ),
  964. )
  965. );
  966. }
  967. /**
  968. * Add a link to exit recovery mode when Recovery Mode is active.
  969. *
  970. * @since 5.2.0
  971. *
  972. * @param WP_Admin_Bar $wp_admin_bar
  973. */
  974. function wp_admin_bar_recovery_mode_menu( $wp_admin_bar ) {
  975. if ( ! wp_is_recovery_mode() ) {
  976. return;
  977. }
  978. $url = wp_login_url();
  979. $url = add_query_arg( 'action', WP_Recovery_Mode::EXIT_ACTION, $url );
  980. $url = wp_nonce_url( $url, WP_Recovery_Mode::EXIT_ACTION );
  981. $wp_admin_bar->add_node(
  982. array(
  983. 'parent' => 'top-secondary',
  984. 'id' => 'recovery-mode',
  985. 'title' => __( 'Exit Recovery Mode' ),
  986. 'href' => $url,
  987. )
  988. );
  989. }
  990. /**
  991. * Add secondary menus.
  992. *
  993. * @since 3.3.0
  994. *
  995. * @param WP_Admin_Bar $wp_admin_bar
  996. */
  997. function wp_admin_bar_add_secondary_groups( $wp_admin_bar ) {
  998. $wp_admin_bar->add_group(
  999. array(
  1000. 'id' => 'top-secondary',
  1001. 'meta' => array(
  1002. 'class' => 'ab-top-secondary',
  1003. ),
  1004. )
  1005. );
  1006. $wp_admin_bar->add_group(
  1007. array(
  1008. 'parent' => 'wp-logo',
  1009. 'id' => 'wp-logo-external',
  1010. 'meta' => array(
  1011. 'class' => 'ab-sub-secondary',
  1012. ),
  1013. )
  1014. );
  1015. }
  1016. /**
  1017. * Style and scripts for the admin bar.
  1018. *
  1019. * @since 3.1.0
  1020. */
  1021. function wp_admin_bar_header() {
  1022. $type_attr = current_theme_supports( 'html5', 'style' ) ? '' : ' type="text/css"';
  1023. ?>
  1024. <style<?php echo $type_attr; ?> media="print">#wpadminbar { display:none; }</style>
  1025. <?php
  1026. }
  1027. /**
  1028. * Default admin bar callback.
  1029. *
  1030. * @since 3.1.0
  1031. */
  1032. function _admin_bar_bump_cb() {
  1033. $type_attr = current_theme_supports( 'html5', 'style' ) ? '' : ' type="text/css"';
  1034. ?>
  1035. <style<?php echo $type_attr; ?> media="screen">
  1036. html { margin-top: 32px !important; }
  1037. * html body { margin-top: 32px !important; }
  1038. @media screen and ( max-width: 782px ) {
  1039. html { margin-top: 46px !important; }
  1040. * html body { margin-top: 46px !important; }
  1041. }
  1042. </style>
  1043. <?php
  1044. }
  1045. /**
  1046. * Sets the display status of the admin bar.
  1047. *
  1048. * This can be called immediately upon plugin load. It does not need to be called
  1049. * from a function hooked to the {@see 'init'} action.
  1050. *
  1051. * @since 3.1.0
  1052. *
  1053. * @global bool $show_admin_bar
  1054. *
  1055. * @param bool $show Whether to allow the admin bar to show.
  1056. */
  1057. function show_admin_bar( $show ) {
  1058. global $show_admin_bar;
  1059. $show_admin_bar = (bool) $show;
  1060. }
  1061. /**
  1062. * Determines whether the admin bar should be showing.
  1063. *
  1064. * For more information on this and similar theme functions, check out
  1065. * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/
  1066. * Conditional Tags} article in the Theme Developer Handbook.
  1067. *
  1068. * @since 3.1.0
  1069. *
  1070. * @global bool $show_admin_bar
  1071. * @global string $pagenow
  1072. *
  1073. * @return bool Whether the admin bar should be showing.
  1074. */
  1075. function is_admin_bar_showing() {
  1076. global $show_admin_bar, $pagenow;
  1077. // For all these types of requests, we never want an admin bar.
  1078. if ( defined( 'XMLRPC_REQUEST' ) || defined( 'DOING_AJAX' ) || defined( 'IFRAME_REQUEST' ) || wp_is_json_request() ) {
  1079. return false;
  1080. }
  1081. if ( is_embed() ) {
  1082. return false;
  1083. }
  1084. // Integrated into the admin.
  1085. if ( is_admin() ) {
  1086. return true;
  1087. }
  1088. if ( ! isset( $show_admin_bar ) ) {
  1089. if ( ! is_user_logged_in() || 'wp-login.php' === $pagenow ) {
  1090. $show_admin_bar = false;
  1091. } else {
  1092. $show_admin_bar = _get_admin_bar_pref();
  1093. }
  1094. }
  1095. /**
  1096. * Filters whether to show the admin bar.
  1097. *
  1098. * Returning false to this hook is the recommended way to hide the admin bar.
  1099. * The user's display preference is used for logged in users.
  1100. *
  1101. * @since 3.1.0
  1102. *
  1103. * @param bool $show_admin_bar Whether the admin bar should be shown. Default false.
  1104. */
  1105. $show_admin_bar = apply_filters( 'show_admin_bar', $show_admin_bar );
  1106. return $show_admin_bar;
  1107. }
  1108. /**
  1109. * Retrieve the admin bar display preference of a user.
  1110. *
  1111. * @since 3.1.0
  1112. * @access private
  1113. *
  1114. * @param string $context Context of this preference check. Defaults to 'front'. The 'admin'
  1115. * preference is no longer used.
  1116. * @param int $user Optional. ID of the user to check, defaults to 0 for current user.
  1117. * @return bool Whether the admin bar should be showing for this user.
  1118. */
  1119. function _get_admin_bar_pref( $context = 'front', $user = 0 ) {
  1120. $pref = get_user_option( "show_admin_bar_{$context}", $user );
  1121. if ( false === $pref ) {
  1122. return true;
  1123. }
  1124. return 'true' === $pref;
  1125. }