_results_for_authed_user(self): 172
+        self.client.force_authenticate(user=self.user)
173
+        resp = self.client.get("/api/opportunities/recommend/?k=10")
174
+        self.assertEqual(resp.status_code, 200)
175
+        self.assertIsInstance(resp.json(), list)

+ 62 - 0
api/urls.py

@@ -0,0 +1,62 @@
1
+from django.urls import path, include
2
+from rest_framework.routers import DefaultRouter
3
+from .views import ProfileViewSet, OpportunityViewSet, IntroductionRequestViewSet, LeaderboardView, LeaderboardMeView
4
+from recycle_core.views_api import (
5
+    OrganizationViewSet,
6
+    MaterialCategoryViewSet,
7
+    MaterialViewSet,
8
+    PriceListViewSet,
9
+    PriceListItemViewSet,
10
+    CustomerViewSet,
11
+    CustomerSiteViewSet,
12
+    ServiceAgreementViewSet,
13
+    PickupOrderViewSet,
14
+    PickupItemViewSet,
15
+    WeighTicketViewSet,
16
+    WeighLineViewSet,
17
+    InvoiceViewSet,
18
+    InvoiceLineViewSet,
19
+    PaymentViewSet,
20
+    PayoutViewSet,
21
+    ScrapListingViewSet,
22
+    ScrapListingItemViewSet,
23
+    ScrapBidViewSet,
24
+    ScrapAwardViewSet,
25
+    ScrapListingInviteViewSet,
26
+    DocumentViewSet,
27
+    AuditLogViewSet,
28
+)
29
+
30
+router = DefaultRouter()
31
+router.register(r'profiles', ProfileViewSet)
32
+router.register(r'opportunities', OpportunityViewSet)
33
+router.register(r'introduction-requests', IntroductionRequestViewSet)
34
+router.register(r'organizations', OrganizationViewSet)
35
+router.register(r'material-categories', MaterialCategoryViewSet)
36
+router.register(r'materials', MaterialViewSet)
37
+router.register(r'price-lists', PriceListViewSet)
38
+router.register(r'price-list-items', PriceListItemViewSet)
39
+router.register(r'customers', CustomerViewSet)
40
+router.register(r'customer-sites', CustomerSiteViewSet)
41
+router.register(r'service-agreements', ServiceAgreementViewSet)
42
+router.register(r'pickups', PickupOrderViewSet)
43
+router.register(r'pickup-items', PickupItemViewSet)
44
+router.register(r'weigh-tickets', WeighTicketViewSet)
45
+router.register(r'weigh-lines', WeighLineViewSet)
46
+router.register(r'invoices', InvoiceViewSet)
47
+router.register(r'invoice-lines', InvoiceLineViewSet)
48
+router.register(r'payments', PaymentViewSet)
49
+router.register(r'payouts', PayoutViewSet)
50
+router.register(r'scrap-listings', ScrapListingViewSet)
51
+router.register(r'scrap-listing-items', ScrapListingItemViewSet)
52
+router.register(r'scrap-bids', ScrapBidViewSet)
53
+router.register(r'scrap-awards', ScrapAwardViewSet)
54
+router.register(r'scrap-listing-invites', ScrapListingInviteViewSet)
55
+router.register(r'documents', DocumentViewSet)
56
+router.register(r'audit-logs', AuditLogViewSet)
57
+
58
+urlpatterns = [
59
+    path('', include(router.urls)),
60
+    path('leaderboard/', LeaderboardView.as_view({'get': 'list'})),
61
+    path('leaderboard/me/', LeaderboardMeView.as_view({'get': 'list'})),
62
+]

+ 114 - 0
api/views.py

@@ -0,0 +1,114 @@
1
+from rest_framework import viewsets
2
+from rest_framework.permissions import IsAuthenticated, AllowAny
3
+from rest_framework.decorators import action
4
+from rest_framework.response import Response
5
+from django_filters.rest_framework import DjangoFilterBackend
6
+from django.db.models import Q
7
+from .models import Profile, Opportunity, IntroductionRequest
8
+from .serializers import ProfileSerializer, OpportunitySerializer, IntroductionRequestSerializer, LeaderRowSerializer
9
+from .services.matching import rank_users_for, rank_opportunities_for
10
+from .services.benefit import build_leaderboard_queryset
11
+
12
+class ProfileViewSet(viewsets.ModelViewSet):
13
+    queryset = Profile.objects.all()
14
+    serializer_class = ProfileSerializer
15
+    permission_classes = [IsAuthenticated]
16
+    filter_backends = [DjangoFilterBackend]
17
+    filterset_fields = {
18
+        'user__username': ['exact', 'icontains'],
19
+        'is_verified': ['exact'],
20
+        'industry': ['exact', 'icontains'],
21
+        'interests': ['icontains'],
22
+        'tags__name': ['exact', 'icontains'],
23
+    }
24
+
25
+    @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
26
+    def match(self, request):
27
+        """Return top user matches for the authenticated user."""
28
+        ranked = rank_users_for(request.user, k=int(request.query_params.get('k', 20)))
29
+        payload = [
30
+            {
31
+                'score': round(item.score, 4),
32
+                'profile': ProfileSerializer(item.profile, context={'request': request}).data,
33
+            }
34
+            for item in ranked
35
+        ]
36
+        return Response(payload)
37
+
38
+class OpportunityViewSet(viewsets.ModelViewSet):
39
+    queryset = Opportunity.objects.all()
40
+    serializer_class = OpportunitySerializer
41
+    permission_classes = [IsAuthenticated]
42
+    filter_backends = [DjangoFilterBackend]
43
+    filterset_fields = {
44
+        'title': ['exact', 'icontains'],
45
+        'created_at': ['gte', 'lte'],
46
+    }
47
+
48
+    @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
49
+    def recommend(self, request):
50
+        """Return top opportunity recommendations for the authenticated user."""
51
+        ranked = rank_opportunities_for(request.user, k=int(request.query_params.get('k', 20)))
52
+        payload = [
53
+            {
54
+                'score': round(item.score, 4),
55
+                'opportunity': OpportunitySerializer(item.opportunity, context={'request': request}).data,
56
+            }
57
+            for item in ranked
58
+        ]
59
+        return Response(payload)
60
+
61
+class IntroductionRequestViewSet(viewsets.ModelViewSet):
62
+    queryset = IntroductionRequest.objects.all()
63
+    serializer_class = IntroductionRequestSerializer
64
+    permission_classes = [IsAuthenticated]
65
+    filter_backends = [DjangoFilterBackend]
66
+    filterset_fields = {
67
+        'from_user__username': ['exact', 'icontains'],
68
+        'to_user__username': ['exact', 'icontains'],
69
+        'is_accepted': ['exact'],
70
+        'message': ['icontains'],
71
+    }
72
+
73
+    def get_queryset(self):
74
+        return IntroductionRequest.objects.filter(
75
+            Q(from_user=self.request.user) | Q(to_user=self.request.user)
76
+        )
77
+
78
+    def perform_create(self, serializer):
79
+        serializer.save(from_user=self.request.user)
80
+
81
+
82
+class LeaderboardView(viewsets.ViewSet):
83
+    permission_classes = [AllowAny]
84
+
85
+    def list(self, request):
86
+        period = request.query_params.get('period', 'weekly')
87
+        try:
88
+            limit = min(int(request.query_params.get('limit', 100)), 500)
89
+        except Exception:
90
+            limit = 100
91
+        try:
92
+            offset = int(request.query_params.get('offset', 0))
93
+        except Exception:
94
+            offset = 0
95
+        qs = build_leaderboard_queryset(period)
96
+        page = qs[offset:offset+limit]
97
+        data = LeaderRowSerializer(page, many=True).data
98
+        return Response({"period": period, "offset": offset, "limit": limit, "results": data})
99
+
100
+
101
+class LeaderboardMeView(viewsets.ViewSet):
102
+    permission_classes = [IsAuthenticated]
103
+
104
+    def list(self, request):
105
+        period = request.query_params.get('period', 'weekly')
106
+        qs = build_leaderboard_queryset(period)
107
+        me = qs.filter(id=request.user.id).first()
108
+        if not me:
109
+            return Response({
110
+                "period": period,
111
+                "result": None,
112
+                "detail": "No rank for current user in this period.",
113
+            })
114
+        return Response({"period": period, "result": LeaderRowSerializer(me).data})

+ 2 - 0
cms/__init__.py

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

+ 19 - 0
cms/admin.py

@@ -0,0 +1,19 @@
1
+from django.contrib import admin
2
+from mptt.admin import MPTTModelAdmin
3
+from .models import Post, PostCategory
4
+
5
+
6
+@admin.register(PostCategory)
7
+class PostCategoryAdmin(MPTTModelAdmin):
8
+    list_display = ("name", "slug", "created_at")
9
+    search_fields = ("name", "slug")
10
+    prepopulated_fields = {"slug": ("name",)}
11
+
12
+
13
+@admin.register(Post)
14
+class PostAdmin(admin.ModelAdmin):
15
+    list_display = ("title", "category", "status", "published_at", "created_at")
16
+    list_filter = ("status", "category")
17
+    search_fields = ("title", "slug", "content")
18
+    prepopulated_fields = {"slug": ("title",)}
19
+    autocomplete_fields = ("category", "author")

+ 8 - 0
cms/apps.py

@@ -0,0 +1,8 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class CmsConfig(AppConfig):
5
+    default_auto_field = "django.db.models.BigAutoField"
6
+    name = "cms"
7
+    verbose_name = "CMS"
8
+

+ 45 - 0
cms/filters.py

@@ -0,0 +1,45 @@
1
+from __future__ import annotations
2
+
3
+import django_filters as filters
4
+from crispy_forms.helper import FormHelper
5
+from crispy_forms.layout import Layout, Field
6
+
7
+from .models import Post, PostCategory
8
+
9
+
10
+class PostFilter(filters.FilterSet):
11
+    title = filters.CharFilter(field_name="title", lookup_expr="icontains", label="Title")
12
+    category = filters.ModelChoiceFilter(
13
+        field_name="category",
14
+        queryset=PostCategory.objects.all(),
15
+        label="Category",
16
+    )
17
+    status = filters.ChoiceFilter(field_name="status", choices=Post.STATUS_CHOICES, label="Status")
18
+    published_at = filters.DateTimeFromToRangeFilter(
19
+        field_name="published_at",
20
+        label="Published between",
21
+        widget=filters.widgets.RangeWidget(
22
+            attrs={
23
+                "type": "datetime-local",
24
+                "style": "color-scheme: light;",
25
+                "class": "border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
26
+            }
27
+        ),
28
+    )
29
+
30
+    class Meta:
31
+        model = Post
32
+        fields = ["title", "category", "status", "published_at"]
33
+
34
+    def __init__(self, *args, **kwargs):
35
+        super().__init__(*args, **kwargs)
36
+        helper = FormHelper()
37
+        helper.form_tag = False
38
+        helper.layout = Layout(
39
+            Field("title"),
40
+            Field("category"),
41
+            Field("status"),
42
+            Field("published_at"),
43
+        )
44
+        self.form.helper = helper
45
+

+ 93 - 0
cms/forms.py

@@ -0,0 +1,93 @@
1
+from django import forms
2
+from django.contrib.auth import get_user_model
3
+from crispy_forms.helper import FormHelper
4
+from crispy_forms.layout import Layout, Field
5
+from markdownfield.widgets import MDEWidget
6
+from .widgets import ImagePreviewWidget
7
+
8
+from .models import Post, PostCategory
9
+from mptt.forms import TreeNodeChoiceField
10
+
11
+
12
+User = get_user_model()
13
+
14
+
15
+class PostCategoryForm(forms.ModelForm):
16
+    class Meta:
17
+        model = PostCategory
18
+        fields = ["name", "slug", "parent", "description"]
19
+
20
+    def __init__(self, *args, **kwargs):
21
+        super().__init__(*args, **kwargs)
22
+        self.helper = FormHelper()
23
+        self.helper.form_tag = False
24
+        self.helper.layout = Layout(
25
+            Field("name"),
26
+            Field("slug"),
27
+            Field("parent"),
28
+            Field("description"),
29
+        )
30
+
31
+    def __init__(self, *args, **kwargs):
32
+        super().__init__(*args, **kwargs)
33
+        # Use TreeNodeChoiceField for hierarchical parent selection
34
+        self.fields["parent"] = TreeNodeChoiceField(
35
+            queryset=PostCategory.objects.all(), required=False, level_indicator="— "
36
+        )
37
+
38
+
39
+class PostForm(forms.ModelForm):
40
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
41
+
42
+    class Meta:
43
+        model = Post
44
+        fields = [
45
+            "title",
46
+            "slug",
47
+            "category",
48
+            "author",
49
+            "excerpt",
50
+            "feature_image",
51
+            "content",
52
+            "status",
53
+            "published_at",
54
+        ]
55
+        widgets = {
56
+            "published_at": forms.DateTimeInput(attrs={"type": "datetime-local"}),
57
+            "content": MDEWidget(attrs={"rows": 20}),
58
+            "feature_image": ImagePreviewWidget(),
59
+        }
60
+
61
+    def __init__(self, *args, **kwargs):
62
+        super().__init__(*args, **kwargs)
63
+        self.helper = FormHelper()
64
+        self.helper.form_tag = False
65
+        self.helper.layout = Layout(
66
+            Field("title"),
67
+            Field("slug"),
68
+            Field("category"),
69
+            Field("author"),
70
+            Field("excerpt"),
71
+            Field("feature_image"),
72
+            Field("content"),
73
+            Field("status"),
74
+            Field("published_at"),
75
+            Field("tags"),
76
+        )
77
+        # Initialize tags from instance
78
+        if self.instance and getattr(self.instance, "pk", None):
79
+            try:
80
+                names = list(self.instance.tags.names())
81
+                self.fields["tags"].initial = ", ".join(names)
82
+            except Exception:
83
+                self.fields["tags"].initial = ""
84
+
85
+    def save(self, commit=True):
86
+        obj = super().save(commit)
87
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
88
+        try:
89
+            if commit:
90
+                obj.tags.set(tags)
91
+        except Exception:
92
+            pass
93
+        return obj

+ 1 - 0
cms/management/__init__.py

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

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

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

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

@@ -0,0 +1,83 @@
1
+from __future__ import annotations
2
+
3
+from django.core.management.base import BaseCommand
4
+
5
+import os
6
+from pathlib import Path
7
+from django.conf import settings
8
+from django.core.files import File
9
+
10
+from cms.models import create_demo_data, PostCategory, Post
11
+from orgs.models import Organization
12
+
13
+
14
+class Command(BaseCommand):
15
+    help = "Seed demo categories and posts for the CMS app (optionally per organization)"
16
+
17
+    def handle(self, *args, **options):
18
+        org_ident = options.get("org")
19
+        org = None
20
+        if org_ident:
21
+            if org_ident.isdigit():
22
+                org = Organization.objects.filter(pk=int(org_ident)).first()
23
+            if org is None:
24
+                org = Organization.objects.filter(code=org_ident).first() or Organization.objects.filter(name=org_ident).first()
25
+            if org is None:
26
+                raise SystemExit(self.style.ERROR(f"Organization not found: {org_ident}"))
27
+        # Ensure root category per org if org is provided
28
+        if org is not None:
29
+            root_name = f"{org.code}_root"
30
+            root_slug = f"{org.code}-root"
31
+            root, _ = PostCategory.objects.get_or_create(
32
+                organization=org,
33
+                slug=root_slug,
34
+                defaults={
35
+                    "name": root_name,
36
+                    "parent": None,
37
+                    "description": f"Root category for {org.code}",
38
+                },
39
+            )
40
+
41
+        result = create_demo_data(org=org)
42
+
43
+        # Move all top-level categories under the root (excluding the root itself)
44
+        if org is not None:
45
+            # Re-fetch root to be safe
46
+            root = PostCategory.objects.get(organization=org, slug=f"{org.code}-root")
47
+            top_levels = PostCategory.objects.filter(organization=org, parent__isnull=True).exclude(pk=root.pk)
48
+            for cat in top_levels:
49
+                cat.parent = root
50
+                cat.save()
51
+
52
+        # Attach demo feature images from static/demo/ if available
53
+        try:
54
+            demo_dir = Path(settings.BASE_DIR) / "static" / "demo"
55
+            if demo_dir.exists() and demo_dir.is_dir():
56
+                image_files = [p for p in sorted(demo_dir.iterdir()) if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}]
57
+            else:
58
+                image_files = []
59
+        except Exception:
60
+            image_files = []
61
+
62
+        if image_files:
63
+            posts_qs = Post.objects.all().order_by("id")
64
+            if org is not None:
65
+                posts_qs = posts_qs.filter(organization=org)
66
+            idx = 0
67
+            for post in posts_qs:
68
+                if not getattr(post, "feature_image", None) or not post.feature_image:
69
+                    img_path = image_files[idx % len(image_files)]
70
+                    try:
71
+                        with open(img_path, "rb") as fh:
72
+                            post.feature_image.save(img_path.name, File(fh), save=True)
73
+                    except Exception:
74
+                        # Skip on any IO error and continue with next
75
+                        pass
76
+                    idx += 1
77
+        suffix = f" for org {org.code}" if org else ""
78
+        self.stdout.write(self.style.SUCCESS(
79
+            f"CMS demo data seeded{suffix}: categories={result.get('categories', 0)}, posts={result.get('posts', 0)}"
80
+        ))
81
+
82
+    def add_arguments(self, parser):
83
+        parser.add_argument("--org", help="Organization code, id, or name to seed CMS data for", default=None)

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

@@ -0,0 +1,89 @@
1
+# Generated by Django 4.2.24 on 2025-09-21 09:39
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import markdownfield.models
7
+import mptt.fields
8
+import taggit.managers
9
+
10
+
11
+class Migration(migrations.Migration):
12
+
13
+    initial = True
14
+
15
+    dependencies = [
16
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+        ('orgs', '__first__'),
18
+        ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
19
+    ]
20
+
21
+    operations = [
22
+        migrations.CreateModel(
23
+            name='PostCategory',
24
+            fields=[
25
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
26
+                ('name', models.CharField(max_length=120)),
27
+                ('slug', models.SlugField(blank=True, max_length=140)),
28
+                ('description', models.TextField(blank=True)),
29
+                ('created_at', models.DateTimeField(auto_now_add=True)),
30
+                ('updated_at', models.DateTimeField(auto_now=True)),
31
+                ('lft', models.PositiveIntegerField(editable=False)),
32
+                ('rght', models.PositiveIntegerField(editable=False)),
33
+                ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
34
+                ('level', models.PositiveIntegerField(editable=False)),
35
+                ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cms_categories', to='orgs.organization')),
36
+                ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='cms.postcategory')),
37
+            ],
38
+            options={
39
+                'ordering': ('tree_id', 'lft', 'name'),
40
+            },
41
+        ),
42
+        migrations.CreateModel(
43
+            name='Post',
44
+            fields=[
45
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
46
+                ('title', models.CharField(max_length=200)),
47
+                ('slug', models.SlugField(blank=True, max_length=220)),
48
+                ('excerpt', models.TextField(blank=True)),
49
+                ('feature_image', models.ImageField(blank=True, null=True, upload_to='posts/feature/%Y/%m/%d/')),
50
+                ('content', markdownfield.models.MarkdownField(rendered_field='content_html')),
51
+                ('content_html', markdownfield.models.RenderedMarkdownField(null=True)),
52
+                ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=12)),
53
+                ('published_at', models.DateTimeField(blank=True, null=True)),
54
+                ('created_at', models.DateTimeField(auto_now_add=True)),
55
+                ('updated_at', models.DateTimeField(auto_now=True)),
56
+                ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to=settings.AUTH_USER_MODEL)),
57
+                ('category', mptt.fields.TreeForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='posts', to='cms.postcategory')),
58
+                ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cms_posts', to='orgs.organization')),
59
+                ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
60
+            ],
61
+            options={
62
+                'ordering': ('-published_at', '-created_at', '-id'),
63
+            },
64
+        ),
65
+        migrations.AddIndex(
66
+            model_name='postcategory',
67
+            index=models.Index(fields=['slug'], name='cms_postcat_slug_2a9a66_idx'),
68
+        ),
69
+        migrations.AddIndex(
70
+            model_name='postcategory',
71
+            index=models.Index(fields=['tree_id', 'lft'], name='cms_postcategory_tree_id_laed4'),
72
+        ),
73
+        migrations.AlterUniqueTogether(
74
+            name='postcategory',
75
+            unique_together={('organization', 'slug')},
76
+        ),
77
+        migrations.AddIndex(
78
+            model_name='post',
79
+            index=models.Index(fields=['status', 'published_at'], name='cms_post_status_c7074b_idx'),
80
+        ),
81
+        migrations.AddIndex(
82
+            model_name='post',
83
+            index=models.Index(fields=['slug'], name='cms_post_slug_90607d_idx'),
84
+        ),
85
+        migrations.AlterUniqueTogether(
86
+            name='post',
87
+            unique_together={('organization', 'slug')},
88
+        ),
89
+    ]

+ 0 - 0
cms/migrations/__init__.py


+ 203 - 0
cms/models.py

@@ -0,0 +1,203 @@
1
+from __future__ import annotations
2
+
3
+from django.db import models
4
+from django.contrib.auth import get_user_model
5
+from django.utils.text import slugify
6
+from textwrap import dedent
7
+from django.utils import timezone
8
+from taggit.managers import TaggableManager
9
+from mptt.models import MPTTModel, TreeForeignKey
10
+from markdownfield.models import MarkdownField, RenderedMarkdownField
11
+from markdownfield.validators import VALIDATOR_STANDARD
12
+from orgs.models import Organization
13
+
14
+from typing import Optional
15
+User = get_user_model()
16
+
17
+
18
+class PostCategory(MPTTModel):
19
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_categories", null=True, blank=True)
20
+    name = models.CharField(max_length=120)
21
+    slug = models.SlugField(max_length=140, unique=False, blank=True)
22
+    description = models.TextField(blank=True)
23
+    parent = TreeForeignKey(
24
+        "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
25
+    )
26
+    created_at = models.DateTimeField(auto_now_add=True)
27
+    updated_at = models.DateTimeField(auto_now=True)
28
+
29
+    class MPTTMeta:
30
+        order_insertion_by = ["name"]
31
+
32
+    class Meta:
33
+        ordering = ("tree_id", "lft", "name")
34
+        indexes = [
35
+            models.Index(fields=["slug"]),
36
+        ]
37
+        unique_together = (("organization", "slug"),)
38
+
39
+    def save(self, *args, **kwargs):
40
+        if not self.slug:
41
+            self.slug = slugify(self.name)[:140]
42
+        super().save(*args, **kwargs)
43
+
44
+    def __str__(self) -> str:  # pragma: no cover - trivial
45
+        return self.name
46
+
47
+
48
+class Post(models.Model):
49
+    STATUS_DRAFT = "draft"
50
+    STATUS_PUBLISHED = "published"
51
+    STATUS_CHOICES = (
52
+        (STATUS_DRAFT, "Draft"),
53
+        (STATUS_PUBLISHED, "Published"),
54
+    )
55
+
56
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_posts", null=True, blank=True)
57
+    title = models.CharField(max_length=200)
58
+    slug = models.SlugField(max_length=220, unique=False, blank=True)
59
+    category = TreeForeignKey(PostCategory, on_delete=models.PROTECT, related_name="posts")
60
+    author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts")
61
+    excerpt = models.TextField(blank=True)
62
+    feature_image = models.ImageField(upload_to="posts/feature/%Y/%m/%d/", null=True, blank=True)
63
+    # Markdown content source + auto-rendered HTML
64
+    content = MarkdownField(rendered_field="content_html", validator=VALIDATOR_STANDARD)
65
+    content_html = RenderedMarkdownField(blank=True, null=True)
66
+    status = models.CharField(max_length=12, choices=STATUS_CHOICES, default=STATUS_DRAFT)
67
+    published_at = models.DateTimeField(null=True, blank=True)
68
+    created_at = models.DateTimeField(auto_now_add=True)
69
+    updated_at = models.DateTimeField(auto_now=True)
70
+
71
+    # Optional tags, consistent with project usage
72
+    tags = TaggableManager(blank=True)
73
+
74
+    class Meta:
75
+        ordering = ("-published_at", "-created_at", "-id")
76
+        indexes = [
77
+            models.Index(fields=["status", "published_at"]),
78
+            models.Index(fields=["slug"]),
79
+        ]
80
+        unique_together = (("organization", "slug"),)
81
+
82
+    def save(self, *args, **kwargs):
83
+        if not self.slug and self.title:
84
+            base = slugify(self.title)[:220].strip('-')
85
+            slug = base or "post"
86
+            # Ensure uniqueness within organization by appending -2, -3, ... if needed
87
+            i = 2
88
+            qs = Post.objects
89
+            if self.organization_id:
90
+                qs = qs.filter(organization_id=self.organization_id)
91
+            while qs.filter(slug=slug).exclude(pk=self.pk).exists():
92
+                suffix = f"-{i}"
93
+                slug = (base[: 220 - len(suffix)] + suffix).strip('-') or f"post-{i}"
94
+                i += 1
95
+            self.slug = slug
96
+        if self.status == self.STATUS_PUBLISHED and not self.published_at:
97
+            self.published_at = timezone.now()
98
+        super().save(*args, **kwargs)
99
+
100
+    def __str__(self) -> str:  # pragma: no cover - trivial
101
+        return self.title
102
+
103
+
104
+# --- Demo data helpers -------------------------------------------------------
105
+
106
+def create_demo_data(org: Optional[Organization] = None) -> dict:
107
+    """Create demo categories and posts for CMS.
108
+
109
+    Idempotent: safe to call multiple times. Returns a summary counts dict.
110
+    """
111
+    created = {"categories": 0, "posts": 0}
112
+
113
+    # Categories
114
+    cat_specs = [
115
+        {"name": "News", "description": "Company news and announcements"},
116
+        {"name": "Guides", "description": "How-to guides and tutorials"},
117
+        {"name": "Updates", "description": "Product updates and release notes"},
118
+    ]
119
+    cats: dict[str, PostCategory] = {}
120
+    for spec in cat_specs:
121
+        slug = slugify(spec["name"])[:140]
122
+        lookup = {"slug": slug}
123
+        if org is not None:
124
+            lookup["organization"] = org
125
+        obj, was_created = PostCategory.objects.get_or_create(
126
+            **lookup,
127
+            defaults={
128
+                "name": spec["name"],
129
+                "description": spec.get("description", ""),
130
+                "organization": org,
131
+            },
132
+        )
133
+        if was_created:
134
+            created["categories"] += 1
135
+        cats[obj.name] = obj
136
+
137
+    # Choose an author if available
138
+    try:
139
+        author = User.objects.filter(is_staff=True).order_by("id").first() or User.objects.order_by("id").first()
140
+    except Exception:
141
+        author = None
142
+
143
+    # Posts
144
+    posts_specs = [
145
+        {
146
+            "title": "Welcome to Ecoloop",
147
+            "category": cats.get("News"),
148
+            "excerpt": "Introducing our recycling operations platform.",
149
+            "content": "# Welcome to Ecoloop\n\nEcoloop helps factories and recyclers manage pickups, pricing, and a scrap marketplace.\nThis is a demo post seeded for your environment.",
150
+            "status": Post.STATUS_PUBLISHED,
151
+            "tags": ["ecoloop", "news"],
152
+        },
153
+        {
154
+            "title": "Getting Started Guide",
155
+            "category": cats.get("Guides"),
156
+            "excerpt": "How to create materials, customers, and pickups.",
157
+            "content": "# Getting Started\n\n1. Create your organization materials and price list.\n2. Add customers and sites.\n3. Create a pickup and record a weigh ticket.\n4. Generate invoices and payouts.",
158
+            "status": Post.STATUS_PUBLISHED,
159
+            "tags": ["guide", "howto"],
160
+        },
161
+        {
162
+            "title": "September Release Notes",
163
+            "category": cats.get("Updates"),
164
+            "excerpt": "Highlights from the latest release.",
165
+            "content": "# Release Notes\n\n- New scrap listings and bids\n- Auto award expired listings\n- Admin UI for pickups and invoices",
166
+            "status": Post.STATUS_DRAFT,
167
+            "tags": ["updates", "releases"],
168
+        },
169
+    ]
170
+
171
+    for spec in posts_specs:
172
+        base_slug = slugify(spec["title"])[:220]
173
+        lookup = {"slug": base_slug}
174
+        if org is not None:
175
+            lookup["organization"] = org
176
+        # Normalize multiline markdown content to avoid code-block wrapping
177
+        raw_content = spec.get("content", "")
178
+        content_md = dedent(raw_content).strip()
179
+        excerpt_text = spec.get("excerpt", "")
180
+        if isinstance(excerpt_text, str):
181
+            excerpt_text = dedent(excerpt_text).strip()
182
+        post, was_created = Post.objects.get_or_create(
183
+            **lookup,
184
+            defaults={
185
+                "title": spec["title"],
186
+                "category": spec.get("category") or next(iter(cats.values())),
187
+                "author": author,
188
+                "excerpt": excerpt_text,
189
+                "content": content_md,
190
+                "status": spec.get("status", Post.STATUS_DRAFT),
191
+                "organization": org,
192
+            },
193
+        )
194
+        if was_created:
195
+            created["posts"] += 1
196
+            try:
197
+                tags = spec.get("tags") or []
198
+                if tags:
199
+                    post.tags.set(tags)
200
+            except Exception:
201
+                pass
202
+
203
+    return created

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

@@ -0,0 +1,54 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
3
+{% load mptt_tags %}
4
+
5
+{% block title %}CMS Categories{% endblock %}
6
+
7
+{% block content %}
8
+  <div class="bg-white rounded shadow p-4 mb-6">
9
+    <h1 class="text-xl font-semibold mb-4">Create Category</h1>
10
+    <form method="post">
11
+      {% csrf_token %}
12
+      {{ form|crispy }}
13
+      <div class="mt-3">
14
+        <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
15
+      </div>
16
+    </form>
17
+  </div>
18
+
19
+  <div class="bg-white rounded shadow p-4">
20
+    <div class="flex items-center justify-between mb-3">
21
+      <h2 class="text-lg font-semibold">Categories</h2>
22
+      <form method="get" class="flex items-center gap-2">
23
+        <input type="text" name="q" value="{{ query }}" placeholder="Search name..." class="border rounded px-3 py-2">
24
+        <button class="px-3 py-2 border rounded">Search</button>
25
+      </form>
26
+    </div>
27
+    <div>
28
+      {% if categories %}
29
+        <ul class="text-sm">
30
+          {% recursetree categories %}
31
+            <li class="py-1">
32
+              <div class="flex items-center gap-3">
33
+                <span class="inline-block border-l pl-3" style="margin-left: {{ node.level|add:'0' }}rem;">{{ node.name }}</span>
34
+                <span class="text-gray-500">({{ node.slug }})</span>
35
+                <a class="text-blue-600 hover:underline" href="{% url 'cms:category_edit' node.pk %}">Edit</a>
36
+                <form method="post" action="{% url 'cms:category_delete' node.pk %}" class="inline" onsubmit="return confirm('Delete this category?')">
37
+                  {% csrf_token %}
38
+                  <button class="text-red-600 hover:underline">Delete</button>
39
+                </form>
40
+              </div>
41
+              {% if not node.is_leaf_node %}
42
+                <ul class="ml-4">
43
+                  {{ children }}
44
+                </ul>
45
+              {% endif %}
46
+            </li>
47
+          {% endrecursetree %}
48
+        </ul>
49
+      {% else %}
50
+        <div class="py-3 text-sm text-gray-600">No categories found.</div>
51
+      {% endif %}
52
+    </div>
53
+  </div>
54
+{% endblock %}

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

@@ -0,0 +1,19 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
3
+
4
+{% block title %}Edit Category{% endblock %}
5
+
6
+{% block content %}
7
+  <div class="bg-white rounded shadow p-4 mb-6">
8
+    <h1 class="text-xl font-semibold mb-4">Edit Category</h1>
9
+    <form method="post">
10
+      {% csrf_token %}
11
+      {{ form|crispy }}
12
+      <div class="mt-3 flex gap-3">
13
+        <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
14
+        <a class="px-4 py-2 border rounded" href="{{ cancel_url }}">Cancel</a>
15
+      </div>
16
+    </form>
17
+  </div>
18
+{% endblock %}
19
+

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

@@ -0,0 +1,22 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags breadcrumbs %}
3
+
4
+{% block title %}Edit Post{% endblock %}
5
+
6
+{% block content %}
7
+{% render_breadcrumbs breadcrumbs %}
8
+{{ form.media }}
9
+  <div class="bg-white rounded shadow p-4 mb-6">
10
+    <h1 class="text-xl font-semibold mb-4">Edit Post</h1>
11
+    <form method="post" enctype="multipart/form-data">
12
+      {% csrf_token %}
13
+      {% for field in form %}
14
+        {{ field|as_crispy_field }}
15
+      {% endfor %}
16
+      <div class="mt-3 flex gap-3">
17
+        <button class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
18
+        <a class="px-4 py-2 border rounded" href="{{ cancel_url }}">Cancel</a>
19
+      </div>
20
+    </form>
21
+  </div>
22
+{% endblock %}

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

@@ -0,0 +1,74 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags breadcrumbs %}
3
+
4
+{% block title %}CMS Posts{% endblock %}
5
+
6
+{% block content %}
7
+  {% render_breadcrumbs breadcrumbs %}
8
+      {{ form.media }}
9
+  <div class="bg-white rounded shadow p-4 mb-6">
10
+    <h1 class="text-xl font-semibold mb-4">Create Post</h1>
11
+    <form method="post" enctype="multipart/form-data" novalidate>
12
+      {% csrf_token %}
13
+      {{ form|crispy }}
14
+      <div class="mt-3">
15
+        <button type=submit class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
16
+      </div>
17
+    </form>
18
+  </div>
19
+
20
+  <div class="bg-white rounded shadow p-4">
21
+    <div class="flex items-center justify-between mb-3">
22
+      <h2 class="text-lg font-semibold">Posts</h2>
23
+    </div>
24
+    <form method="get" class="mb-4">
25
+      {% if filter %}
26
+        <div class="flex flex-wrap items-end gap-3">
27
+          {% for f in filter.form %}
28
+            <div class="min-w-[220px]">
29
+              {{ f|as_crispy_field }}
30
+            </div>
31
+          {% endfor %}
32
+          <div class="flex items-center gap-2">
33
+            <button class="px-3 py-2 border rounded">Apply</button>
34
+            <a href="?" class="px-3 py-2 border rounded">Reset</a>
35
+          </div>
36
+        </div>
37
+      {% endif %}
38
+    </form>
39
+    <div class="overflow-x-auto">
40
+      <table class="min-w-full text-sm">
41
+        <thead>
42
+          <tr class="text-left border-b">
43
+            <th class="py-2 pr-4">Title</th>
44
+            <th class="py-2 pr-4">Category</th>
45
+            <th class="py-2 pr-4">Status</th>
46
+            <th class="py-2 pr-4">Published</th>
47
+            <th class="py-2 pr-4">Actions</th>
48
+          </tr>
49
+        </thead>
50
+        <tbody>
51
+          {% for p in posts %}
52
+            <tr class="border-b">
53
+              <td class="py-2 pr-4">{{ p.title }}</td>
54
+              <td class="py-2 pr-4">{{ p.category }}</td>
55
+              <td class="py-2 pr-4">{{ p.get_status_display }}</td>
56
+              <td class="py-2 pr-4">{{ p.published_at|default:"–" }}</td>
57
+              <td class="py-2 pr-4">
58
+                <a class="text-blue-600 hover:underline" href="{% url 'cms:post_edit' p.pk %}">Edit</a>
59
+                <form method="post" action="{% url 'cms:post_delete' p.pk %}" class="inline" onsubmit="return confirm('Delete this post?')">
60
+                  {% csrf_token %}
61
+                  <button class="text-red-600 hover:underline ml-3">Delete</button>
62
+                </form>
63
+              </td>
64
+            </tr>
65
+          {% empty %}
66
+            <tr><td class="py-3" colspan="5">No posts found.</td></tr>
67
+          {% endfor %}
68
+        </tbody>
69
+      </table>
70
+    </div>
71
+
72
+    {% include "admin_frontend/_pagination.html" %}
73
+  </div>
74
+{% endblock %}

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

@@ -0,0 +1,48 @@
1
+{# Custom image upload with preview, built on ClearableFileInput #}
2
+<div class="space-y-2">
3
+  <div>
4
+    {% if widget.is_initial and widget.value %}
5
+      {% if widget.value.url %}
6
+        <img id="preview_{{ widget.attrs.id }}" src="{{ widget.value.url }}" alt="Current image" class="max-h-56 rounded border" />
7
+      {% else %}
8
+        <img id="preview_{{ widget.attrs.id }}" alt="Current image" class="hidden max-h-56 rounded border" />
9
+      {% endif %}
10
+    {% else %}
11
+      <img id="preview_{{ widget.attrs.id }}" alt="Preview" class="hidden max-h-56 rounded border" />
12
+    {% endif %}
13
+  </div>
14
+
15
+  <div class="flex items-center gap-3">
16
+    <input type="file" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />
17
+    <span id="filename_{{ widget.attrs.id }}" class="text-sm text-gray-600 truncate"></span>
18
+    {% if not widget.required and widget.is_initial %}
19
+      <label class="inline-flex items-center gap-2 text-sm text-gray-700">
20
+        <input type="checkbox" name="{{ widget.clear_checkbox_name }}" id="{{ widget.clear_checkbox_id }}" />
21
+        {{ widget.clear_checkbox_label }}
22
+      </label>
23
+    {% endif %}
24
+  </div>
25
+
26
+  {% if widget.is_initial and widget.value and widget.value.url %}
27
+    <div>
28
+      <a href="{{ widget.value.url }}" target="_blank" class="text-xs text-blue-600 hover:underline">Open current image</a>
29
+    </div>
30
+  {% endif %}
31
+
32
+  <script>
33
+    (function(){
34
+      const input = document.getElementById('{{ widget.attrs.id }}');
35
+      const preview = document.getElementById('preview_{{ widget.attrs.id }}');
36
+      const filenameEl = document.getElementById('filename_{{ widget.attrs.id }}');
37
+      if (!input || !preview) return;
38
+      input.addEventListener('change', function (e) {
39
+        const file = input.files && input.files[0];
40
+        if (!file) return;
41
+        if (filenameEl) filenameEl.textContent = file.name;
42
+        const url = URL.createObjectURL(file);
43
+        preview.src = url;
44
+        preview.classList.remove('hidden');
45
+      });
46
+    })();
47
+  </script>
48
+</div>

+ 1 - 0
cms/templatetags/__init__.py

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

+ 24 - 0
cms/templatetags/breadcrumbs.py

@@ -0,0 +1,24 @@
1
+from __future__ import annotations
2
+
3
+from django import template
4
+
5
+register = template.Library()
6
+
7
+
8
+@register.inclusion_tag("admin_frontend/_breadcrumbs.html", takes_context=True)
9
+def render_breadcrumbs(context, breadcrumbs=None, aria_label: str = "Breadcrumb"):
10
+    """
11
+    Render breadcrumbs using a Tailwind-friendly partial.
12
+
13
+    Usage in templates:
14
+      {% load breadcrumbs %}
15
+      {% render_breadcrumbs breadcrumbs %}
16
+
17
+    "breadcrumbs" should be a list of (label, url) tuples.
18
+    Set url to None for the current (active) crumb.
19
+    """
20
+    request = context.get("request")
21
+    crumbs = breadcrumbs or context.get("breadcrumbs")
22
+    if crumbs is None and request is not None:
23
+        crumbs = getattr(request, "_breadcrumbs", [])
24
+    return {"breadcrumbs": crumbs or [], "aria_label": aria_label, "request": request}

+ 41 - 0
cms/tests.py

@@ -0,0 +1,41 @@
1
+from django.test import TestCase
2
+from django.contrib.auth import get_user_model
3
+
4
+from .models import Post, PostCategory
5
+
6
+
7
+class CmsModelsTests(TestCase):
8
+    def setUp(self):
9
+        self.user = get_user_model().objects.create_user(username="writer", password="x")
10
+
11
+    def test_create_category_and_post(self):
12
+        cat = PostCategory.objects.create(name="News")
13
+        self.assertTrue(cat.slug)
14
+
15
+        post = Post.objects.create(
16
+            title="Hello World",
17
+            category=cat,
18
+            author=self.user,
19
+            content="Body",
20
+            status=Post.STATUS_DRAFT,
21
+        )
22
+        self.assertTrue(post.slug)
23
+        self.assertEqual(post.status, Post.STATUS_DRAFT)
24
+
25
+    def test_publish_sets_published_at(self):
26
+        cat = PostCategory.objects.create(name="Updates")
27
+        post = Post.objects.create(title="Release", category=cat, content="v1", status=Post.STATUS_PUBLISHED)
28
+        self.assertIsNotNone(post.published_at)
29
+
30
+    def test_markdown_renders_to_html(self):
31
+        cat = PostCategory.objects.create(name="Docs")
32
+        post = Post.objects.create(
33
+            title="Markdown Test",
34
+            category=cat,
35
+            content="# Hello\n\nThis is **bold**.",
36
+            status=Post.STATUS_DRAFT,
37
+        )
38
+        # Rendered HTML should be populated
39
+        self.assertIsNotNone(post.content_html)
40
+        self.assertIn("<h1>", post.content_html)
41
+        self.assertIn("<strong>", post.content_html)

+ 15 - 0
cms/urls.py

@@ -0,0 +1,15 @@
1
+from django.urls import path
2
+from . import views
3
+
4
+app_name = "cms"
5
+
6
+urlpatterns = [
7
+    path("posts/", views.posts_list, name="posts_list"),
8
+    path("posts/<int:pk>/edit/", views.post_edit, name="post_edit"),
9
+    path("posts/<int:pk>/delete/", views.post_delete, name="post_delete"),
10
+
11
+    path("categories/", views.categories_list, name="categories_list"),
12
+    path("categories/<int:pk>/edit/", views.category_edit, name="category_edit"),
13
+    path("categories/<int:pk>/delete/", views.category_delete, name="category_delete"),
14
+]
15
+

+ 308 - 0
cms/views.py

@@ -0,0 +1,308 @@
1
+from django.shortcuts import render, redirect, get_object_or_404
2
+from django.contrib.admin.views.decorators import staff_member_required
3
+from django.views.decorators.http import require_POST
4
+from django.contrib import messages
5
+from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
+from django.urls import reverse, get_resolver, URLPattern, URLResolver, NoReverseMatch
7
+from functools import wraps
8
+from typing import Any, Dict, List, Optional, Tuple
9
+
10
+from admin_frontend.templatetags.public_urls import public_route
11
+from admin_frontend.nav import _nav_items
12
+
13
+from .models import Post, PostCategory
14
+from .forms import PostForm, PostCategoryForm
15
+from .filters import PostFilter
16
+
17
+
18
+# --- Lightweight breadcrumbs decorator -------------------------------------
19
+
20
+def _iter_patterns(resolver: URLResolver, ns_path: str = ""):
21
+    for entry in resolver.url_patterns:
22
+        if isinstance(entry, URLResolver):
23
+            new_ns = ns_path
24
+            if entry.namespace:
25
+                new_ns = f"{ns_path}:{entry.namespace}" if ns_path else entry.namespace
26
+            yield from _iter_patterns(entry, new_ns)
27
+        elif isinstance(entry, URLPattern):
28
+            full_name = entry.name
29
+            if full_name and ns_path:
30
+                full_name = f"{ns_path}:{full_name}"
31
+            yield entry, full_name
32
+
33
+
34
+def _find_pattern_by_breadcrumb_name(name: str) -> Tuple[Optional[URLPattern], Optional[str]]:
35
+    resolver = get_resolver()
36
+    for pattern, full_name in _iter_patterns(resolver):
37
+        if not full_name:
38
+            continue
39
+        view = pattern.callback
40
+        # unwrap to find metadata on inner functions
41
+        meta = None
42
+        cur = view
43
+        while cur is not None and meta is None:
44
+            meta = getattr(cur, "_breadcrumbs_meta", None)
45
+            cur = getattr(cur, "__wrapped__", None)
46
+        if meta and meta.get("name") == name:
47
+            return pattern, full_name
48
+    return None, None
49
+
50
+
51
+def _build_breadcrumb_chain(meta: Dict[str, Any]) -> List[Tuple[str, Optional[str]]]:
52
+    chain: List[Tuple[str, Optional[str]]] = []
53
+    # Walk parent chain (by name) outward
54
+    parent_name = meta.get("parent")
55
+    while parent_name:
56
+        pattern, full_name = _find_pattern_by_breadcrumb_name(parent_name)
57
+        if not pattern:
58
+            break
59
+        view = pattern.callback
60
+        vmeta = None
61
+        cur = view
62
+        while cur is not None and vmeta is None:
63
+            vmeta = getattr(cur, "_breadcrumbs_meta", None)
64
+            cur = getattr(cur, "__wrapped__", None)
65
+        vmeta = vmeta or {}
66
+        label = vmeta.get("label") or parent_name
67
+        # Try to reverse parent URL (skip if args required)
68
+        url: Optional[str] = None
69
+        if full_name:
70
+            try:
71
+                url = reverse(full_name)
72
+            except NoReverseMatch:
73
+                url = None
74
+        chain.insert(0, (label, url))
75
+        parent_name = vmeta.get("parent")
76
+    # Add current at the end (no URL)
77
+    chain.append((meta.get("label") or "", None))
78
+    return chain
79
+
80
+
81
+def breadcrumbs(*, label: str, name: Optional[str] = None, parent: Optional[str] = None):
82
+    """Decorator to attach breadcrumb metadata and expose a computed trail.
83
+
84
+    Usage:
85
+      @breadcrumbs(label="Posts", name="posts")
86
+      def posts_list(...): ...
87
+
88
+      @breadcrumbs(label="Post Edit", parent="posts")
89
+      def post_edit(...): ...
90
+    """
91
+
92
+    def _decorator(view_func):
93
+        meta = {"label": label, "name": name, "parent": parent}
94
+
95
+        @wraps(view_func)
96
+        def _wrapped(request, *args, **kwargs):
97
+            # Expose breadcrumbs via request for templates/tags to consume
98
+            request._breadcrumbs = _build_breadcrumb_chain(meta)
99
+            return view_func(request, *args, **kwargs)
100
+
101
+        # Make metadata discoverable on the resolved callback
102
+        setattr(_wrapped, "_breadcrumbs_meta", meta)
103
+        return _wrapped
104
+
105
+    return _decorator
106
+
107
+
108
+@staff_member_required
109
+@breadcrumbs(label="Posts", name="posts")
110
+@public_route(label="CMS Posts", order=70, icon="document-text")
111
+def posts_list(request):
112
+    if request.method == "POST":
113
+        form = PostForm(request.POST, request.FILES)
114
+        if form.is_valid():
115
+            obj = form.save(commit=False)
116
+            # Scope to current organization if available
117
+            org = getattr(request, "org", None)
118
+            if org is not None:
119
+                obj.organization = org
120
+            obj.save()
121
+            messages.success(request, "Post created.")
122
+            return redirect("cms:posts_list")
123
+        else:
124
+            messages.error(request, "Please correct the errors below.")
125
+    else:
126
+        form = PostForm()
127
+
128
+    # Restrict category choices to current organization
129
+    org = getattr(request, "org", None)
130
+    if org is not None:
131
+        try:
132
+            form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
133
+        except Exception:
134
+            pass
135
+
136
+    qs = Post.objects.select_related("category", "author").all().order_by("-published_at", "-created_at")
137
+    if org is not None:
138
+        qs = qs.filter(organization=org)
139
+    post_filter = PostFilter(request.GET, queryset=qs)
140
+    # Also scope the filter's category queryset
141
+    if org is not None:
142
+        try:
143
+            post_filter.form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
144
+        except Exception:
145
+            pass
146
+    paginator = Paginator(post_filter.qs, 10)
147
+    page = request.GET.get("page")
148
+    try:
149
+        page_obj = paginator.page(page)
150
+    except PageNotAnInteger:
151
+        page_obj = paginator.page(1)
152
+    except EmptyPage:
153
+        page_obj = paginator.page(paginator.num_pages)
154
+
155
+    context = {
156
+        "nav": _nav_items(),
157
+        "posts": page_obj.object_list,
158
+        "page_obj": page_obj,
159
+        "form": form,
160
+        "filter": post_filter,
161
+    }
162
+    return render(request, "cms/posts_list.html", context)
163
+
164
+
165
+@staff_member_required
166
+@breadcrumbs(label="Post Edit", parent="posts")
167
+def post_edit(request, pk: int):
168
+    org = getattr(request, "org", None)
169
+    base_qs = Post.objects.all()
170
+    if org is not None:
171
+        base_qs = base_qs.filter(organization=org)
172
+    item = get_object_or_404(base_qs, pk=pk)
173
+    if request.method == "POST":
174
+        form = PostForm(request.POST, request.FILES, instance=item)
175
+        if form.is_valid():
176
+            obj = form.save(commit=False)
177
+            org = getattr(request, "org", None)
178
+            if org is not None:
179
+                obj.organization = org
180
+            obj.save()
181
+            messages.success(request, "Post updated.")
182
+            return redirect("cms:post_edit", pk=item.pk)
183
+    else:
184
+        form = PostForm(instance=item)
185
+
186
+    # Restrict category choices
187
+    org = getattr(request, "org", None)
188
+    if org is not None:
189
+        try:
190
+            form.fields["category"].queryset = PostCategory.objects.filter(organization=org)
191
+        except Exception:
192
+            pass
193
+
194
+    context = {
195
+        "nav": _nav_items(),
196
+        "item": item,
197
+        "form": form,
198
+        "cancel_url": reverse("cms:posts_list"),
199
+    }
200
+    return render(request, "cms/post_form.html", context)
201
+
202
+
203
+@staff_member_required
204
+@require_POST
205
+def post_delete(request, pk: int):
206
+    org = getattr(request, "org", None)
207
+    base_qs = Post.objects.all()
208
+    if org is not None:
209
+        base_qs = base_qs.filter(organization=org)
210
+    item = get_object_or_404(base_qs, pk=pk)
211
+    item.delete()
212
+    messages.success(request, "Post deleted.")
213
+    return redirect("cms:posts_list")
214
+
215
+
216
+@staff_member_required
217
+@public_route(label="CMS Categories", order=75, icon="tag")
218
+def categories_list(request):
219
+    if request.method == "POST":
220
+        form = PostCategoryForm(request.POST)
221
+        if form.is_valid():
222
+            obj = form.save(commit=False)
223
+            org = getattr(request, "org", None)
224
+            if org is not None:
225
+                obj.organization = org
226
+            obj.save()
227
+            messages.success(request, "Category created.")
228
+            return redirect("cms:categories_list")
229
+        else:
230
+            messages.error(request, "Please correct the errors below.")
231
+    else:
232
+        form = PostCategoryForm()
233
+
234
+    q = (request.GET.get("q") or "").strip()
235
+    qs = PostCategory.objects.all().order_by("name")
236
+    org = getattr(request, "org", None)
237
+    if org is not None:
238
+        qs = qs.filter(organization=org)
239
+    if q:
240
+        qs = qs.filter(name__icontains=q)
241
+
242
+    paginator = Paginator(qs, 15)
243
+    page = request.GET.get("page")
244
+    try:
245
+        page_obj = paginator.page(page)
246
+    except PageNotAnInteger:
247
+        page_obj = paginator.page(1)
248
+    except EmptyPage:
249
+        page_obj = paginator.page(paginator.num_pages)
250
+
251
+    context = {
252
+        "nav": _nav_items(),
253
+        "categories": qs,
254
+        "page_obj": page_obj,
255
+        "form": form,
256
+        "query": q,
257
+    }
258
+    return render(request, "cms/categories_list.html", context)
259
+
260
+
261
+@staff_member_required
262
+def category_edit(request, pk: int):
263
+    org = getattr(request, "org", None)
264
+    base_qs = PostCategory.objects.all()
265
+    if org is not None:
266
+        base_qs = base_qs.filter(organization=org)
267
+    item = get_object_or_404(base_qs, pk=pk)
268
+    if request.method == "POST":
269
+        form = PostCategoryForm(request.POST, instance=item)
270
+        if form.is_valid():
271
+            obj = form.save(commit=False)
272
+            org = getattr(request, "org", None)
273
+            if org is not None:
274
+                obj.organization = org
275
+            obj.save()
276
+            messages.success(request, "Category updated.")
277
+            return redirect("cms:category_edit", pk=item.pk)
278
+    else:
279
+        form = PostCategoryForm(instance=item)
280
+
281
+    # Restrict parent choices
282
+    org = getattr(request, "org", None)
283
+    if org is not None:
284
+        try:
285
+            form.fields["parent"].queryset = PostCategory.objects.filter(organization=org)
286
+        except Exception:
287
+            pass
288
+
289
+    context = {
290
+        "nav": _nav_items(),
291
+        "item": item,
292
+        "form": form,
293
+        "cancel_url": reverse("cms:categories_list"),
294
+    }
295
+    return render(request, "cms/category_form.html", context)
296
+
297
+
298
+@staff_member_required
299
+@require_POST
300
+def category_delete(request, pk: int):
301
+    org = getattr(request, "org", None)
302
+    base_qs = PostCategory.objects.all()
303
+    if org is not None:
304
+        base_qs = base_qs.filter(organization=org)
305
+    item = get_object_or_404(base_qs, pk=pk)
306
+    item.delete()
307
+    messages.success(request, "Category deleted.")
308
+    return redirect("cms:categories_list")

+ 27 - 0
cms/widgets.py

@@ -0,0 +1,27 @@
1
+from __future__ import annotations
2
+
3
+from django.forms.widgets import ClearableFileInput
4
+
5
+
6
+class ImagePreviewWidget(ClearableFileInput):
7
+    template_name = "cms/widgets/image_preview_input.html"
8
+
9
+    def __init__(self, attrs=None):
10
+        attrs = {**({} if attrs is None else attrs), "accept": "image/*"}
11
+        super().__init__(attrs=attrs)
12
+
13
+    def get_context(self, name, value, attrs):
14
+        # Merge our button-like classes into any provided classes without duplicating the class attribute in template
15
+        base_classes = (
16
+            "file:mr-3 file:px-3 file:py-2 file:rounded file:border-0 "
17
+            "file:bg-blue-600 file:text-white hover:file:bg-blue-700 text-sm text-gray-700"
18
+        )
19
+        attrs = attrs or {}
20
+        existing = (attrs.get("class") or self.attrs.get("class") or "").strip()
21
+        merged = (existing + " " + base_classes).strip() if existing else base_classes
22
+        attrs["class"] = merged
23
+        return super().get_context(name, value, attrs)
24
+
25
+    class Media:
26
+        css = {"all": []}
27
+        js = []

+ 23 - 0
docker-compose.yml

@@ -0,0 +1,23 @@
1
+version: '3.8'
2
+
3
+services:
4
+  web:
5
+    build: .
6
+    command: sh -c "python manage.py tailwind dev & python manage.py runserver 0.0.0.0:8000"
7
+    volumes:
8
+      - .:/app
9
+    ports:
10
+      - "8024:8000"
11
+    depends_on:
12
+      - db
13
+    env_file:
14
+      - .env
15
+  db:
16
+    image: postgres:13
17
+    volumes:
18
+      - postgres_data:/var/lib/postgresql/data/
19
+    env_file:
20
+      - .env
21
+
22
+volumes:
23
+  postgres_data:

+ 0 - 0
ecoloop/__init__.py


+ 16 - 0
ecoloop/asgi.py

@@ -0,0 +1,16 @@
1
+"""
2
+ASGI config for lux_backend project.
3
+
4
+It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+For more information on this file, see
7
+https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
8
+"""
9
+
10
+import os
11
+
12
+from django.core.asgi import get_asgi_application
13
+
14
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
15
+
16
+application = get_asgi_application()

+ 28 - 0
ecoloop/context_processors.py

@@ -0,0 +1,28 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any, Dict
4
+
5
+try:
6
+    from django.contrib.sites.models import Site
7
+except Exception:  # pragma: no cover - sites may be disabled in some envs
8
+    Site = None  # type: ignore
9
+
10
+
11
+def current_site(request) -> Dict[str, Any]:
12
+    """Inject the current Site object as `current_site` into templates.
13
+
14
+    Falls back to None if Sites is not available.
15
+    """
16
+    site = None
17
+    # Prefer host-based resolution so different domains work even when SITE_ID is set.
18
+    if Site is not None:
19
+        try:
20
+            host = request.get_host().split(":")[0].lower()
21
+            site = Site.objects.get(domain__iexact=host)
22
+        except Exception:
23
+            try:
24
+                # Fallback to the default configured Site
25
+                site = Site.objects.get_current()
26
+            except Exception:
27
+                site = None
28
+    return {"current_site": site}

+ 25 - 0
ecoloop/middleware.py

@@ -0,0 +1,25 @@
1
+from __future__ import annotations
2
+
3
+from django.utils.deprecation import MiddlewareMixin
4
+from django.contrib.auth.views import redirect_to_login
5
+
6
+
7
+class WebAdminAuthRedirectMiddleware(MiddlewareMixin):
8
+    """Redirect unauthenticated /webadmin/* requests to /webadmin/login/.
9
+
10
+    This ensures that when a session expires (or user is anonymous), accessing
11
+    any webadmin page leads to the webadmin login screen, regardless of the
12
+    global LOGIN_URL setting.
13
+    """
14
+
15
+    def process_request(self, request):
16
+        path = request.path
17
+        if not path.startswith("/webadmin/"):
18
+            return None
19
+        if request.user.is_authenticated:
20
+            return None
21
+        # Allow the login and logout endpoints without redirect loop
22
+        if path.startswith("/webadmin/login/") or path.startswith("/webadmin/logout/"):
23
+            return None
24
+        return redirect_to_login(request.get_full_path(), login_url="/webadmin/login/")
25
+

+ 281 - 0
ecoloop/settings.py

@@ -0,0 +1,281 @@
1
+"""
2
+Django settings for lux_backend project.
3
+
4
+Generated by 'django-admin startproject' using Django 4.2.23.
5
+
6
+For more information on this file, see
7
+https://docs.djangoproject.com/en/4.2/topics/settings/
8
+
9
+For the full list of settings and their values, see
10
+https://docs.djangoproject.com/en/4.2/ref/settings/
11
+"""
12
+
13
+from pathlib import Path
14
+import os
15
+from dotenv import load_dotenv
16
+
17
+load_dotenv()
18
+
19
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
20
+BASE_DIR = Path(__file__).resolve().parent.parent
21
+
22
+
23
+# Quick-start development settings - unsuitable for production
24
+# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
25
+
26
+# SECURITY WARNING: keep the secret key used in production secret!
27
+SECRET_KEY = os.getenv('SECRET_KEY')
28
+
29
+# SECURITY WARNING: don't run with debug turned on in production!
30
+DEBUG = os.getenv('DEBUG') == '1'
31
+
32
+ALLOWED_HOSTS = [h for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1,.lvh.me').split(',') if h]
33
+
34
+# CSRF trusted origins must include scheme and port when applicable
35
+_csrf_env = os.getenv('CSRF_TRUSTED_ORIGINS', '')
36
+if _csrf_env:
37
+    CSRF_TRUSTED_ORIGINS = [o for o in _csrf_env.split(',') if o]
38
+else:
39
+    CSRF_TRUSTED_ORIGINS = [
40
+        'http://localhost:8000',
41
+        'http://127.0.0.1:8000',
42
+        'http://localhost:8024',
43
+        'http://127.0.0.1:8024',
44
+        'http://*.lvh.me:8000',
45
+        'http://*.lvh.me:8024',
46
+    ]
47
+
48
+
49
+# Application definition
50
+
51
+INSTALLED_APPS = [
52
+    'django.contrib.admin',
53
+    'django.contrib.auth',
54
+    'django.contrib.contenttypes',
55
+    'django.contrib.sessions',
56
+    'django.contrib.messages',
57
+    'django.contrib.sites',
58
+    'rest_framework',
59
+    'djoser',
60
+    'drf_spectacular',
61
+    'django_filters',
62
+    'taggit',
63
+    'mptt',
64
+    'tailwind',
65
+    'crispy_forms',
66
+    'crispy_tailwind',
67
+    'markdownfield',
68
+    'django.contrib.staticfiles',
69
+    'allauth',
70
+    'allauth.account',
71
+    'allauth.socialaccount',
72
+    'allauth.socialaccount.providers.google',
73
+    'allauth.socialaccount.providers.facebook',
74
+    'allauth.socialaccount.providers.line',
75
+    'orgs',
76
+    'public_frontend',
77
+    'theme',
78
+    'settings',
79
+    'admin_frontend',
80
+    'frontend',
81
+    'api',
82
+    'cms',
83
+    'recycle_core'
84
+]
85
+if DEBUG:
86
+    # Add django_browser_reload only in DEBUG mode
87
+    INSTALLED_APPS += ['django_browser_reload']
88
+
89
+
90
+TAILWIND_APP_NAME = 'theme'
91
+
92
+# django-crispy-forms (Tailwind)
93
+CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
94
+CRISPY_TEMPLATE_PACK = "tailwind"
95
+
96
+STATICFILES_DIRS = [
97
+    BASE_DIR / "static",
98
+]
99
+
100
+MIDDLEWARE = [
101
+    'django.middleware.security.SecurityMiddleware',
102
+    'django.contrib.sessions.middleware.SessionMiddleware',
103
+    'django.middleware.common.CommonMiddleware',
104
+    'django.middleware.csrf.CsrfViewMiddleware',
105
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
106
+    'ecoloop.middleware.WebAdminAuthRedirectMiddleware',
107
+    'orgs.middleware.CurrentOrganizationMiddleware',
108
+    'django.contrib.messages.middleware.MessageMiddleware',
109
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
110
+    "allauth.account.middleware.AccountMiddleware",
111
+]
112
+
113
+if DEBUG:
114
+    # Add django_browser_reload middleware only in DEBUG mode
115
+    MIDDLEWARE += [
116
+        "django_browser_reload.middleware.BrowserReloadMiddleware",
117
+    ]
118
+
119
+
120
+ROOT_URLCONF = 'ecoloop.urls'
121
+
122
+TEMPLATES = [
123
+    {
124
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
125
+        'DIRS': [BASE_DIR / 'templates'],
126
+        'APP_DIRS': True,
127
+        'OPTIONS': {
128
+            'context_processors': [
129
+                'django.template.context_processors.debug',
130
+                'django.template.context_processors.request',
131
+                'django.contrib.auth.context_processors.auth',
132
+                'django.contrib.messages.context_processors.messages',
133
+                'ecoloop.context_processors.current_site',
134
+            ],
135
+        },
136
+    },
137
+]
138
+
139
+WSGI_APPLICATION = 'ecoloop.wsgi.application'
140
+
141
+
142
+# Database
143
+# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
144
+
145
+DATABASES = {
146
+    'default': {
147
+        'ENGINE': 'django.db.backends.postgresql',
148
+        'NAME': os.getenv('DB_NAME'),
149
+        'USER': os.getenv('DB_USER'),
150
+        'PASSWORD': os.getenv('DB_PASSWORD'),
151
+        'HOST': os.getenv('DB_HOST'),
152
+        'PORT': os.getenv('DB_PORT'),
153
+    }
154
+}
155
+
156
+
157
+# Password validation
158
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
159
+
160
+AUTH_PASSWORD_VALIDATORS = [
161
+    {
162
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
163
+    },
164
+    {
165
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
166
+    },
167
+    {
168
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
169
+    },
170
+    {
171
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
172
+    },
173
+]
174
+
175
+
176
+# Internationalization
177
+# https://docs.djangoproject.com/en/4.2/topics/i18n/
178
+
179
+LANGUAGE_CODE = 'en-us'
180
+
181
+TIME_ZONE = 'UTC'
182
+
183
+USE_I18N = True
184
+
185
+USE_TZ = True
186
+
187
+
188
+# Static and media files
189
+# https://docs.djangoproject.com/en/4.2/howto/static-files/
190
+
191
+STATIC_URL = 'static/'
192
+
193
+# Media (uploaded) files
194
+MEDIA_URL = '/media/'
195
+MEDIA_ROOT = BASE_DIR / 'media'
196
+STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
197
+
198
+
199
+# Default primary key field type
200
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
201
+
202
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
203
+
204
+REST_FRAMEWORK = {
205
+    'DEFAULT_AUTHENTICATION_CLASSES': (
206
+        'rest_framework_simplejwt.authentication.JWTAuthentication',
207
+    ),
208
+    'DEFAULT_FILTER_BACKENDS': (
209
+        'django_filters.rest_framework.DjangoFilterBackend',
210
+    ),
211
+    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
212
+}
213
+
214
+SPECTACULAR_SETTINGS = {
215
+    'TITLE': 'Ecoloop API',
216
+    'DESCRIPTION': 'API for the Ecoloop recycling management system.',
217
+    'VERSION': '1.0.0',
218
+    'SERVE_INCLUDE_SCHEMA': False,
219
+}
220
+
221
+# Auth redirects
222
+LOGIN_URL = '/webadmin/login/'
223
+LOGIN_REDIRECT_URL = '/webadmin/'
224
+
225
+# Taggit behavior
226
+TAGGIT_CASE_INSENSITIVE = True
227
+
228
+# django-allauth config
229
+SITE_ID = 1
230
+
231
+AUTHENTICATION_BACKENDS = [
232
+    'django.contrib.auth.backends.ModelBackend',
233
+    'allauth.account.auth_backends.AuthenticationBackend',
234
+]
235
+
236
+SOCIALACCOUNT_PROVIDERS = {
237
+    'google': {
238
+        'APP': {
239
+            'client_id': os.getenv('GOOGLE_CLIENT_ID'),
240
+            'secret': os.getenv('GOOGLE_CLIENT_SECRET'),
241
+            'key': ''
242
+        },
243
+        'SCOPE': [
244
+            'profile',
245
+            'email',
246
+        ],
247
+        'AUTH_PARAMS': {
248
+            'access_type': 'online',
249
+        }
250
+    },
251
+    'line': {
252
+              'APP': {
253
+                  'client_id': os.getenv('LINE_LOGIN_CHANNEL_ID'),
254
+                  'secret': os.getenv('LINE_LOGIN_CHANNEL_SECRET')
255
+              },
256
+              "SCOPE": ['profile', 'openid', 'email']
257
+    },
258
+    'facebook': {
259
+        'METHOD': 'oauth2',  # Set to 'js_sdk' to use the Facebook connect SDK
260
+        'SDK_URL': '//connect.facebook.net/{locale}/sdk.js',
261
+        'SCOPE': ['email', 'public_profile'],
262
+        'AUTH_PARAMS': {'auth_type': 'reauthenticate'},
263
+        'INIT_PARAMS': {'cookie': True},
264
+        'FIELDS': [
265
+            'id',
266
+            'first_name',
267
+            'last_name',
268
+            'middle_name',
269
+            'name',
270
+            'name_format',
271
+            'picture',
272
+            'short_name'
273
+        ],
274
+        'EXCHANGE_TOKEN': True,
275
+        'LOCALE_FUNC': 'path.to.callable',
276
+        'VERIFIED_EMAIL': False,
277
+        'VERSION': 'v13.0',
278
+        'GRAPH_API_URL': 'https://graph.facebook.com/v13.0',
279
+    }
280
+}
281
+SITE_URL = "http://localhost:8000/"

+ 49 - 0
ecoloop/urls.py

@@ -0,0 +1,49 @@
1
+"""
2
+URL configuration for lux_backend project.
3
+
4
+The `urlpatterns` list routes URLs to views. For more information please see:
5
+    https://docs.djangoproject.com/en/4.2/topics/http/urls/
6
+Examples:
7
+Function views
8
+    1. Add an import:  from my_app import views
9
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
10
+Class-based views
11
+    1. Add an import:  from other_app.views import Home
12
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
13
+Including another URLconf
14
+    1. Import the include() function: from django.urls import include, path
15
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
16
+"""
17
+from django.contrib import admin
18
+from django.urls import path, include
19
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
20
+from django.conf import settings
21
+from django.conf.urls.static import static
22
+
23
+urlpatterns = [
24
+    path('', include('public_frontend.urls')),
25
+    path('lux/', include('frontend.urls')),
26
+    path('admin/', admin.site.urls),
27
+    path('api/', include('api.urls')),
28
+    path('auth/', include('djoser.urls')),
29
+    path('auth/', include('djoser.urls.jwt')),
30
+    path('accounts/', include('allauth.urls')),
31
+    path('webadmin/', include('admin_frontend.urls')),
32
+    path('webadmin/orgs/', include('orgs.urls_admin')),
33
+    path('webadmin/settings/', include('settings.urls')),
34
+    path('webadmin/cms/', include('cms.urls')),
35
+    path('webadmin/recycle/', include('recycle_core.urls')),
36
+    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
37
+    path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
38
+    path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
39
+]
40
+
41
+if settings.DEBUG:
42
+    # Include django_browser_reload URLs only in DEBUG mode
43
+    urlpatterns += [
44
+        path("__reload__/", include("django_browser_reload.urls")),
45
+    ]
46
+    # Serve user-uploaded media files in development
47
+    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
48
+    from django.conf.urls.static import static
49
+    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

+ 16 - 0
ecoloop/wsgi.py

@@ -0,0 +1,16 @@
1
+"""
2
+WSGI config for lux_backend project.
3
+
4
+It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+For more information on this file, see
7
+https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
8
+"""
9
+
10
+import os
11
+
12
+from django.core.wsgi import get_wsgi_application
13
+
14
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
15
+
16
+application = get_wsgi_application()

+ 0 - 0
frontend/__init__.py


+ 3 - 0
frontend/admin.py

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

+ 6 - 0
frontend/apps.py

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

+ 0 - 0
frontend/migrations/__init__.py


+ 3 - 0
frontend/models.py

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

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

@@ -0,0 +1,41 @@
1
+{% load static tailwind_tags %}
2
+<!DOCTYPE html>
3
+<html lang="en">
4
+<head>
5
+    <title>{% block title %}Lux Frontend{% endblock %}</title>
6
+    <meta charset="UTF-8">
7
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+    <meta http-equiv="X-UA-Compatible" content="ie=edge">
9
+    {% tailwind_css %}
10
+</head>
11
+<body class="bg-gray-100 text-gray-900">
12
+    <nav class="bg-white shadow-md">
13
+        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
14
+            <div class="flex justify-between h-16">
15
+                <div class="flex">
16
+                    <div class="flex-shrink-0 flex items-center">
17
+                        <a href="{% url 'frontend:index' %}" class="text-2xl font-bold text-gray-900">Lux</a>
18
+                    </div>
19
+                </div>
20
+                <div class="hidden sm:ml-6 sm:flex sm:space-x-8">
21
+                    <a href="{% url 'frontend:profiles' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Profiles</a>
22
+                    <a href="{% url 'frontend:opportunities' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Opportunities</a>
23
+                    <a href="{% url 'frontend:leaderboard' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Leaderboard</a>
24
+                    {% if user.is_authenticated %}
25
+                        <a href="{% url 'frontend:logout' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Logout</a>
26
+                    {% else %}
27
+                        <a href="{% url 'frontend:login' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Login</a>
28
+                        <a href="{% url 'frontend:signup' %}" class="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium">Sign Up</a>
29
+                    {% endif %}
30
+                </div>
31
+            </div>
32
+        </div>
33
+    </nav>
34
+
35
+    <main class="py-10">
36
+        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
37
+            {% block content %}{% endblock %}
38
+        </div>
39
+    </main>
40
+</body>
41
+</html>

+ 14 - 0
frontend/templates/frontend/index.html

@@ -0,0 +1,14 @@
1
+{% extends 'frontend/base.html' %}
2
+
3
+{% block title %}Welcome to Lux{% endblock %}
4
+
5
+{% block content %}
6
+<div class="text-center">
7
+    <h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">Welcome to Lux</h1>
8
+    <p class="mt-6 text-lg leading-8 text-gray-600">The platform for professional connections and opportunities.</p>
9
+    <div class="mt-10 flex items-center justify-center gap-x-6">
10
+        <a href="{% url 'frontend:profiles' %}" class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">View Profiles</a>
11
+        <a href="{% url 'frontend:opportunities' %}" class="text-sm font-semibold leading-6 text-gray-900">View Opportunities <span aria-hidden="true">→</span></a>
12
+    </div>
13
+</div>
14
+{% endblock %}

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

@@ -0,0 +1,27 @@
1
+{% extends 'frontend/base.html' %}
2
+
3
+{% block title %}Leaderboard{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-3xl font-bold mb-6">Leaderboard</h1>
7
+<div class="bg-white rounded-lg shadow-md">
8
+    <table class="min-w-full divide-y divide-gray-200">
9
+        <thead class="bg-gray-50">
10
+            <tr>
11
+                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
12
+                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
13
+                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Points</th>
14
+            </tr>
15
+        </thead>
16
+        <tbody class="bg-white divide-y divide-gray-200">
17
+            {% for row in leaderboard %}
18
+            <tr>
19
+                <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ row.rank }}</td>
20
+                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ row.username }}</td>
21
+                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ row.points_given }}</td>
22
+            </tr>
23
+            {% endfor %}
24
+        </tbody>
25
+    </table>
26
+</div>
27
+{% endblock %}

Разница между файлами не показана из-за своего большого размера
+ 51 - 0
frontend/templates/frontend/login.html


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

@@ -0,0 +1,16 @@
1
+{% extends 'frontend/base.html' %}
2
+
3
+{% block title %}Opportunities{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-3xl font-bold mb-6">Opportunities</h1>
7
+<div class="space-y-6">
8
+    {% for opportunity in opportunities %}
9
+    <div class="bg-white rounded-lg shadow-md p-6">
10
+        <h2 class="text-xl font-bold">{{ opportunity.title }}</h2>
11
+        <p class="text-gray-600 mt-2">{{ opportunity.description }}</p>
12
+        <p class="text-sm text-gray-500 mt-4">Posted on: {{ opportunity.created_at|date:"F d, Y" }}</p>
13
+    </div>
14
+    {% endfor %}
15
+</div>
16
+{% endblock %}

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

@@ -0,0 +1,21 @@
1
+{% extends 'frontend/base.html' %}
2
+
3
+{% block title %}Profiles{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-3xl font-bold mb-6">Profiles</h1>
7
+<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
8
+    {% for profile in profiles %}
9
+    <div class="bg-white rounded-lg shadow-md p-6">
10
+        <h2 class="text-xl font-bold">{{ profile.user.username }}</h2>
11
+        <p class="text-gray-600">{{ profile.industry }}</p>
12
+        <p class="mt-4">{{ profile.bio }}</p>
13
+        <div class="mt-4">
14
+            {% for tag in profile.tags.all %}
15
+            <span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{{ tag.name }}</span>
16
+            {% endfor %}
17
+        </div>
18
+    </div>
19
+    {% endfor %}
20
+</div>
21
+{% endblock %}

Разница между файлами не показана из-за своего большого размера
+ 34 - 0
frontend/templates/frontend/signup.html


+ 3 - 0
frontend/tests.py

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

+ 14 - 0
frontend/urls.py

@@ -0,0 +1,14 @@
1
+from django.urls import path
2
+from . import views
3
+from django.contrib.auth import views as auth_views
4
+
5
+app_name = 'frontend'
6
+urlpatterns = [
7
+    path('', views.index, name='index'),
8
+    path('profiles/', views.profiles, name='profiles'),
9
+    path('opportunities/', views.opportunities, name='opportunities'),
10
+    path('leaderboard/', views.leaderboard, name='leaderboard'),
11
+    path('login/', auth_views.LoginView.as_view(template_name='frontend/login.html'), name='login'),
12
+    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
13
+    path('signup/', views.signup, name='signup'),
14
+]

+ 31 - 0
frontend/views.py

@@ -0,0 +1,31 @@
1
+from django.shortcuts import render, redirect
2
+from django.contrib.auth import login
3
+from django.contrib.auth.forms import UserCreationForm
4
+from api.models import Profile, Opportunity
5
+from api.services.benefit import build_leaderboard_queryset
6
+
7
+def signup(request):
8
+    if request.method == 'POST':
9
+        form = UserCreationForm(request.POST)
10
+        if form.is_valid():
11
+            user = form.save()
12
+            login(request, user)
13
+            return redirect('frontend:index')
14
+    else:
15
+        form = UserCreationForm()
16
+    return render(request, 'frontend/signup.html', {'form': form})
17
+
18
+def index(request):
19
+    return render(request, 'frontend/index.html')
20
+
21
+def profiles(request):
22
+    profiles = Profile.objects.all()
23
+    return render(request, 'frontend/profiles.html', {'profiles': profiles})
24
+
25
+def opportunities(request):
26
+    opportunities = Opportunity.objects.all()
27
+    return render(request, 'frontend/opportunities.html', {'opportunities': opportunities})
28
+
29
+def leaderboard(request):
30
+    leaderboard = build_leaderboard_queryset('all')
31
+    return render(request, 'frontend/leaderboard.html', {'leaderboard': leaderboard})

+ 22 - 0
manage.py

@@ -0,0 +1,22 @@
1
+#!/usr/bin/env python
2
+"""Django's command-line utility for administrative tasks."""
3
+import os
4
+import sys
5
+
6
+
7
+def main():
8
+    """Run administrative tasks."""
9
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecoloop.settings')
10
+    try:
11
+        from django.core.management import execute_from_command_line
12
+    except ImportError as exc:
13
+        raise ImportError(
14
+            "Couldn't import Django. Are you sure it's installed and "
15
+            "available on your PYTHONPATH environment variable? Did you "
16
+            "forget to activate a virtual environment?"
17
+        ) from exc
18
+    execute_from_command_line(sys.argv)
19
+
20
+
21
+if __name__ == '__main__':
22
+    main()

+ 2 - 0
orgs/__init__.py

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

+ 0 - 0
orgs/admin.py


Некоторые файлы не были показаны из-за большого количества измененных файлов

tum/whitesports - Gogs: Simplico Git Service

Keine Beschreibung

Caption.php 4.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. <?php
  2. /**
  3. * SimplePie
  4. *
  5. * A PHP-Based RSS and Atom Feed Framework.
  6. * Takes the hard work out of managing a complete RSS/Atom solution.
  7. *
  8. * Copyright (c) 2004-2016, Ryan Parman, Sam Sneddon, Ryan McCue, and contributors
  9. * All rights reserved.
  10. *
  11. * Redistribution and use in source and binary forms, with or without modification, are
  12. * permitted provided that the following conditions are met:
  13. *
  14. * * Redistributions of source code must retain the above copyright notice, this list of
  15. * conditions and the following disclaimer.
  16. *
  17. * * Redistributions in binary form must reproduce the above copyright notice, this list
  18. * of conditions and the following disclaimer in the documentation and/or other materials
  19. * provided with the distribution.
  20. *
  21. * * Neither the name of the SimplePie Team nor the names of its contributors may be used
  22. * to endorse or promote products derived from this software without specific prior
  23. * written permission.
  24. *
  25. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
  26. * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
  27. * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS
  28. * AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
  29. * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  30. * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  31. * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
  32. * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
  33. * POSSIBILITY OF SUCH DAMAGE.
  34. *
  35. * @package SimplePie
  36. * @copyright 2004-2016 Ryan Parman, Sam Sneddon, Ryan McCue
  37. * @author Ryan Parman
  38. * @author Sam Sneddon
  39. * @author Ryan McCue
  40. * @link http://simplepie.org/ SimplePie
  41. * @license http://www.opensource.org/licenses/bsd-license.php BSD License
  42. */
  43. /**
  44. * Handles `<media:text>` captions as defined in Media RSS.
  45. *
  46. * Used by {@see SimplePie_Enclosure::get_caption()} and {@see SimplePie_Enclosure::get_captions()}
  47. *
  48. * This class can be overloaded with {@see SimplePie::set_caption_class()}
  49. *
  50. * @package SimplePie
  51. * @subpackage API
  52. */
  53. class SimplePie_Caption
  54. {
  55. /**
  56. * Content type
  57. *
  58. * @var string
  59. * @see get_type()
  60. */
  61. var $type;
  62. /**
  63. * Language
  64. *
  65. * @var string
  66. * @see get_language()
  67. */
  68. var $lang;
  69. /**
  70. * Start time
  71. *
  72. * @var string
  73. * @see get_starttime()
  74. */
  75. var $startTime;
  76. /**
  77. * End time
  78. *
  79. * @var string
  80. * @see get_endtime()
  81. */
  82. var $endTime;
  83. /**
  84. * Caption text
  85. *
  86. * @var string
  87. * @see get_text()
  88. */
  89. var $text;
  90. /**
  91. * Constructor, used to input the data
  92. *
  93. * For documentation on all the parameters, see the corresponding
  94. * properties and their accessors
  95. */
  96. public function __construct($type = null, $lang = null, $startTime = null, $endTime = null, $text = null)
  97. {
  98. $this->type = $type;
  99. $this->lang = $lang;
  100. $this->startTime = $startTime;
  101. $this->endTime = $endTime;
  102. $this->text = $text;
  103. }
  104. /**
  105. * String-ified version
  106. *
  107. * @return string
  108. */
  109. public function __toString()
  110. {
  111. // There is no $this->data here
  112. return md5(serialize($this));
  113. }
  114. /**
  115. * Get the end time
  116. *
  117. * @return string|null Time in the format 'hh:mm:ss.SSS'
  118. */
  119. public function get_endtime()
  120. {
  121. if ($this->endTime !== null)
  122. {
  123. return $this->endTime;
  124. }
  125. return null;
  126. }
  127. /**
  128. * Get the language
  129. *
  130. * @link http://tools.ietf.org/html/rfc3066
  131. * @return string|null Language code as per RFC 3066
  132. */
  133. public function get_language()
  134. {
  135. if ($this->lang !== null)
  136. {
  137. return $this->lang;
  138. }
  139. return null;
  140. }
  141. /**
  142. * Get the start time
  143. *
  144. * @return string|null Time in the format 'hh:mm:ss.SSS'
  145. */
  146. public function get_starttime()
  147. {
  148. if ($this->startTime !== null)
  149. {
  150. return $this->startTime;
  151. }
  152. return null;
  153. }
  154. /**
  155. * Get the text of the caption
  156. *
  157. * @return string|null
  158. */
  159. public function get_text()
  160. {
  161. if ($this->text !== null)
  162. {
  163. return $this->text;
  164. }
  165. return null;
  166. }
  167. /**
  168. * Get the content type (not MIME type)
  169. *
  170. * @return string|null Either 'text' or 'html'
  171. */
  172. public function get_type()
  173. {
  174. if ($this->type !== null)
  175. {
  176. return $this->type;
  177. }
  178. return null;
  179. }
  180. }