um lines-num-old"> 7
+
8
+# Set work directory
9
+WORKDIR /app
10
+
11
+# Install Node.js and npm
12
+RUN apt-get update \
13
+    && apt-get install -y --no-install-recommends nodejs npm \
14
+    && rm -rf /var/lib/apt/lists/*
15
+
16
+# Install dependencies
17
+COPY requirements.txt /app/
18
+RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+# Copy project
21
+COPY . /app/

+ 1 - 0
Procfile.tailwind

@@ -0,0 +1 @@
1
+tailwind: python manage.py tailwind start

+ 43 - 0
README.md

@@ -0,0 +1,43 @@
1
+# Ecoloop Backend
2
+
3
+Ecoloop is a Django + DRF backend for recycling businesses and factories to manage pickups, pricing, invoices/payouts, and a bidding marketplace for scrap lots. It supports multi-organization setups, JWT auth, and a Tailwind-powered UI layer.
4
+
5
+## Quick Start
6
+- Requirements: Python 3.11, Postgres, Node (for Tailwind in dev)
7
+- Environment: copy `.env` and set `SECRET_KEY`, `DEBUG`, `DB_*`, `ALLOWED_HOSTS`
8
+- Run (Docker): `docker-compose up --build` (web on http://localhost:8024)
9
+- Run (local):
10
+  - `python manage.py migrate`
11
+  - `python manage.py runserver`
12
+  - Optional demo: `python manage.py seed_ecoloop`
13
+- API docs: Swagger UI at `/api/schema/swagger-ui/`
14
+
15
+## Project Scopes (Summary)
16
+- Core operations: customers, sites, service agreements, materials, units; pickup lifecycle (request → schedule/assign → collect → weigh ticket → complete).
17
+- Pricing & finance: org/customer price lists (buy/sell), invoice generation from weigh tickets, payments, and payouts for buy-priced materials.
18
+- Bidding marketplace: factories post scrap listings (open/invite-only), recyclers bid with min-increment/reserve; award creates a pickup order.
19
+- Users & portal: roles (owner, manager, driver, customer, auditor); JWT auth; customer/driver-facing endpoints for assignments and status.
20
+- Compliance & docs: upload/attach documents (proofs, photos, certificates); audit logs for key state changes.
21
+- Reporting: benefit leaderboards and volume/revenue summaries (period filters).
22
+- Multi-org & locale: organization scoping, currency codes (THB/USD), units (kg/lb); env-driven config.
23
+
24
+## Key Commands
25
+- `python manage.py migrate` — apply DB migrations
26
+- `python manage.py seed_ecoloop` — seed demo org, users, materials, pickup, weigh ticket, invoice, listing/bid
27
+- `python manage.py ecoloop_auto_close_award` — auto-close/award expired listings
28
+
29
+## Multisite (Local Testing)
30
+- Purpose: Serve multiple sites on one codebase using Django Sites.
31
+- Config:
32
+  - Enabled in `ecoloop/settings.py` via `django.contrib.sites` and `SITE_ID`.
33
+  - `.env` includes defaults for local hosts: `ALLOWED_HOSTS=localhost,127.0.0.1,.lvh.me`
34
+  - `.env` CSRF for Docker port: `CSRF_TRUSTED_ORIGINS=http://localhost:8024,http://*.lvh.me:8024`
35
+- Run:
36
+  - `python manage.py migrate` (applies Sites tables)
37
+  - In Admin → Sites, add domains like `alpha.lvh.me` and `beta.lvh.me`.
38
+  - Visit `http://alpha.lvh.me:8024` and `http://beta.lvh.me:8024`.
39
+  - Attach site-specific content in apps (e.g., CMS) by filtering on `get_current_site(request)` or adding a `ForeignKey` to `Site`.
40
+
41
+## Structure
42
+- `ecoloop/` settings and URLs; `api/` public endpoints; `recycle_core/` domain models/APIs
43
+- Assets: `templates/`, `static/`, `media/`; Entrypoints: `manage.py`, `docker-compose.yml`, `Dockerfile`

+ 0 - 0
admin_frontend/__init__.py


+ 3 - 0
admin_frontend/admin.py

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

+ 6 - 0
admin_frontend/apps.py

@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class AdminFrontendConfig(AppConfig):
5
+    default_auto_field = 'django.db.models.BigAutoField'
6
+    name = 'admin_frontend'

+ 165 - 0
admin_frontend/forms.py

@@ -0,0 +1,165 @@
1
+from django import forms
2
+from django.contrib.auth.models import User
3
+
4
+from api.models import Profile, Opportunity, IntroductionRequest
5
+from crispy_forms.helper import FormHelper
6
+from crispy_forms.layout import Layout, Field
7
+
8
+
9
+class CheckboxBeforeLabel(Field):
10
+    template = "crispy/checkbox_before_label.html"
11
+
12
+
13
+class ProfileCreateForm(forms.Form):
14
+    username = forms.CharField(label="Username")
15
+    bio = forms.CharField(label="Bio", widget=forms.Textarea, required=False)
16
+    interests = forms.CharField(label="Interests", required=False)
17
+    industry = forms.CharField(label="Industry", required=False)
18
+    is_verified = forms.BooleanField(label="Verified", required=False)
19
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
20
+
21
+    def __init__(self, *args, **kwargs):
22
+        super().__init__(*args, **kwargs)
23
+        self.helper = FormHelper()
24
+        self.helper.form_tag = False
25
+        self.helper.layout = Layout(
26
+            Field("username"),
27
+            Field("bio"),
28
+            Field("interests"),
29
+            Field("industry"),
30
+            Field("tags"),
31
+            CheckboxBeforeLabel("is_verified"),
32
+        )
33
+
34
+    def clean_username(self):
35
+        username = self.cleaned_data["username"].strip()
36
+        user = User.objects.filter(username=username).first()
37
+        if not user:
38
+            raise forms.ValidationError("User not found.")
39
+        if Profile.objects.filter(user=user).exists():
40
+            raise forms.ValidationError("Profile for this user already exists.")
41
+        return username
42
+
43
+    def save(self):
44
+        username = self.cleaned_data["username"].strip()
45
+        user = User.objects.get(username=username)
46
+        profile = Profile.objects.create(
47
+            user=user,
48
+            bio=self.cleaned_data.get("bio", ""),
49
+            interests=self.cleaned_data.get("interests", ""),
50
+            industry=self.cleaned_data.get("industry", ""),
51
+            is_verified=self.cleaned_data.get("is_verified", False),
52
+        )
53
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
54
+        try:
55
+            if tags:
56
+                profile.tags.set(tags)
57
+        except Exception:
58
+            pass
59
+        return profile
60
+
61
+
62
+class ProfileEditForm(forms.ModelForm):
63
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
64
+
65
+    class Meta:
66
+        model = Profile
67
+        fields = ["bio", "interests", "industry", "is_verified"]
68
+
69
+    def __init__(self, *args, **kwargs):
70
+        super().__init__(*args, **kwargs)
71
+        self.helper = FormHelper()
72
+        self.helper.form_tag = False
73
+        self.helper.layout = Layout(
74
+            Field("bio"),
75
+            Field("interests"),
76
+            Field("industry"),
77
+            Field("tags"),
78
+            CheckboxBeforeLabel("is_verified"),
79
+        )
80
+        if self.instance and getattr(self.instance, "pk", None):
81
+            try:
82
+                names = list(self.instance.tags.names())
83
+                self.fields["tags"].initial = ", ".join(names)
84
+            except Exception:
85
+                self.fields["tags"].initial = ""
86
+
87
+    def save(self, commit=True):
88
+        profile = super().save(commit)
89
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
90
+        try:
91
+            if commit and hasattr(profile, "tags"):
92
+                profile.tags.set(tags)
93
+        except Exception:
94
+            pass
95
+        return profile
96
+
97
+
98
+class OpportunityForm(forms.ModelForm):
99
+    class Meta:
100
+        model = Opportunity
101
+        fields = ["title", "description"]
102
+
103
+    def __init__(self, *args, **kwargs):
104
+        super().__init__(*args, **kwargs)
105
+        self.helper = FormHelper()
106
+        self.helper.form_tag = False
107
+        self.helper.layout = Layout(
108
+            Field("title"),
109
+            Field("description"),
110
+        )
111
+
112
+
113
+class IntroRequestForm(forms.Form):
114
+    from_username = forms.CharField(label="From Username")
115
+    to_username = forms.CharField(label="To Username")
116
+    message = forms.CharField(label="Message", widget=forms.Textarea)
117
+    is_accepted = forms.BooleanField(label="Accepted", required=False)
118
+
119
+    def __init__(self, *args, **kwargs):
120
+        instance = kwargs.pop("instance", None)
121
+        super().__init__(*args, **kwargs)
122
+        self.instance = instance
123
+        self.helper = FormHelper()
124
+        self.helper.form_tag = False
125
+        self.helper.layout = Layout(
126
+            Field("from_username"),
127
+            Field("to_username"),
128
+            Field("message"),
129
+            CheckboxBeforeLabel("is_accepted"),
130
+        )
131
+        if instance:
132
+            self.fields["from_username"].initial = instance.from_user.username
133
+            self.fields["to_username"].initial = instance.to_user.username
134
+            self.fields["message"].initial = instance.message
135
+            self.fields["is_accepted"].initial = instance.is_accepted
136
+
137
+    def clean(self):
138
+        cleaned = super().clean()
139
+        from_username = cleaned.get("from_username", "").strip()
140
+        to_username = cleaned.get("to_username", "").strip()
141
+        if not from_username or not to_username:
142
+            return cleaned
143
+        from_user = User.objects.filter(username=from_username).first()
144
+        to_user = User.objects.filter(username=to_username).first()
145
+        if not from_user or not to_user:
146
+            raise forms.ValidationError("Both users must exist.")
147
+        if from_user == to_user:
148
+            raise forms.ValidationError("From and To users must be different.")
149
+        cleaned["from_user_obj"] = from_user
150
+        cleaned["to_user_obj"] = to_user
151
+        return cleaned
152
+
153
+    def save(self, instance: IntroductionRequest | None = None):
154
+        data = self.cleaned_data
155
+        if instance is None:
156
+            instance = IntroductionRequest()
157
+        instance.from_user = data["from_user_obj"]
158
+        instance.to_user = data["to_user_obj"]
159
+        instance.message = data.get("message", "")
160
+        instance.is_accepted = data.get("is_accepted", False)
161
+        instance.save()
162
+        return instance
163
+
164
+
165
+# Recycle Core forms moved to recycle_core/forms.py

+ 0 - 0
admin_frontend/migrations/__init__.py


+ 3 - 0
admin_frontend/models.py

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

+ 9 - 0
admin_frontend/nav.py

@@ -0,0 +1,9 @@
1
+def _nav_items():
2
+    return [
3
+        {"name": "Dashboard", "href": "dashboard"},
4
+        {"name": "Profiles", "href": "profiles_list"},
5
+        {"name": "Opportunities", "href": "opportunities_list"},
6
+        {"name": "Intro Requests", "href": "intro_requests_list"},
7
+        {"name": "Settings", "href": "global_settings"},
8
+        {"name": "Advanced Settings", "href": "advanced_settings"},
9
+    ]

+ 17 - 0
admin_frontend/templates/admin_frontend/_breadcrumbs.html

@@ -0,0 +1,17 @@
1
+{% if breadcrumbs %}
2
+<nav aria-label="{{ aria_label|default:'Breadcrumb' }}" class="mb-4">
3
+  <ol class="flex items-center text-sm text-gray-600 gap-2">
4
+    {% for label, url in breadcrumbs %}
5
+      {% if forloop.last or not url %}
6
+        <li class="text-gray-900">{{ label }}</li>
7
+      {% else %}
8
+        <li>
9
+          <a href="{{ url }}" class="hover:underline">{{ label }}</a>
10
+        </li>
11
+        <li class="text-gray-400">/</li>
12
+      {% endif %}
13
+    {% endfor %}
14
+  </ol>
15
+  </nav>
16
+{% endif %}
17
+

+ 24 - 0
admin_frontend/templates/admin_frontend/_form.html

@@ -0,0 +1,24 @@
1
+{% load crispy_forms_tags %}
2
+<form
3
+  method="{{ method|default:'post' }}"
4
+  action="{{ action|default:'' }}"
5
+  class="bg-white rounded shadow p-6 space-y-4"
6
+  {% if enctype %}enctype="{{ enctype }}"{% elif form.is_multipart %}enctype="multipart/form-data"{% endif %}
7
+>
8
+  {% if method|default:'post'|lower == 'post' %}{% csrf_token %}{% endif %}
9
+
10
+  {% if form.non_field_errors %}
11
+    <div class="error px-3 py-2 rounded mb-3">{{ form.non_field_errors }}</div>
12
+  {% endif %}
13
+
14
+  {{ form.media }}
15
+  {{ form|crispy }}
16
+
17
+  <div class="flex items-center gap-2">
18
+    <button class="btn">{{ submit_label|default:'Save' }}</button>
19
+    {% if cancel_url %}
20
+      <a href="{{ cancel_url }}" class="btn-outline">Cancel</a>
21
+    {% endif %}
22
+    {% if extra_buttons %}{{ extra_buttons|safe }}{% endif %}
23
+  </div>
24
+</form>

+ 25 - 0
admin_frontend/templates/admin_frontend/_pagination.html

@@ -0,0 +1,25 @@
1
+{% if page_obj %}
2
+{% with query=request.GET.urlencode %}
3
+<nav class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
4
+  <div class="text-sm text-gray-600">
5
+    Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
6
+  </div>
7
+  <div class="flex items-center gap-2">
8
+    {% if page_obj.has_previous %}
9
+      <a class="btn-outline" href="?{% if query %}{{ query }}&{% endif %}page=1">First</a>
10
+      <a class="btn-outline" href="?{% if query %}{{ query }}&{% endif %}page={{ page_obj.previous_page_number }}">Prev</a>
11
+    {% else %}
12
+      <span class="btn-outline opacity-50 cursor-not-allowed">First</span>
13
+      <span class="btn-outline opacity-50 cursor-not-allowed">Prev</span>
14
+    {% endif %}
15
+    {% if page_obj.has_next %}
16
+      <a class="btn-outline" href="?{% if query %}{{ query }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
17
+      <a class="btn-outline" href="?{% if query %}{{ query }}&{% endif %}page={{ page_obj.paginator.num_pages }}">Last</a>
18
+    {% else %}
19
+      <span class="btn-outline opacity-50 cursor-not-allowed">Next</span>
20
+      <span class="btn-outline opacity-50 cursor-not-allowed">Last</span>
21
+    {% endif %}
22
+  </div>
23
+</nav>
24
+{% endwith %}
25
+{% endif %}

+ 12 - 0
admin_frontend/templates/admin_frontend/advanced_settings.html

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

+ 193 - 0
admin_frontend/templates/admin_frontend/base.html

@@ -0,0 +1,193 @@
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>{{ current_site.name|default:'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">{{ current_site.name|default:'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
+              <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 %}

Файловите разлики са ограничени, защото са твърде много
+ 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 %}

Файловите разлики са ограничени, защото са твърде много
+ 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


Някои файлове не бяха показани, защото твърде много файлове са промени

tum/kaceeapp7 - Gogs: Simplico Git Service

Нема описа

tum c0d28fea79 first commit пре 3 година
..
fixtures c0d28fea79 first commit пре 3 година
specs c0d28fea79 first commit пре 3 година
support c0d28fea79 first commit пре 3 година