class="language-py">+ 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 %}

File diff suppressed because it is too large
+ 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 %}

File diff suppressed because it is too large
+ 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


Some files were not shown because too many files changed in this diff

tum/whitesports - Gogs: Simplico Git Service

Keine Beschreibung

Tum cc318a25cd template vor 4 Jahren
..
nsl.js cc318a25cd template vor 4 Jahren