from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.admin.views.decorators import staff_member_required from django.views.decorators.http import require_POST from django.contrib import messages from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.urls import reverse, get_resolver, URLPattern, URLResolver, NoReverseMatch from functools import wraps from typing import Any, Dict, List, Optional, Tuple from admin_frontend.templatetags.public_urls import public_route from admin_frontend.nav import _nav_items from .models import Post, PostCategory from .forms import PostForm, PostCategoryForm from .filters import PostFilter # --- Lightweight breadcrumbs decorator ------------------------------------- def _iter_patterns(resolver: URLResolver, ns_path: str = ""): for entry in resolver.url_patterns: if isinstance(entry, URLResolver): new_ns = ns_path if entry.namespace: new_ns = f"{ns_path}:{entry.namespace}" if ns_path else entry.namespace yield from _iter_patterns(entry, new_ns) elif isinstance(entry, URLPattern): full_name = entry.name if full_name and ns_path: full_name = f"{ns_path}:{full_name}" yield entry, full_name def _find_pattern_by_breadcrumb_name(name: str) -> Tuple[Optional[URLPattern], Optional[str]]: resolver = get_resolver() for pattern, full_name in _iter_patterns(resolver): if not full_name: continue view = pattern.callback # unwrap to find metadata on inner functions meta = None cur = view while cur is not None and meta is None: meta = getattr(cur, "_breadcrumbs_meta", None) cur = getattr(cur, "__wrapped__", None) if meta and meta.get("name") == name: return pattern, full_name return None, None def _build_breadcrumb_chain(meta: Dict[str, Any]) -> List[Tuple[str, Optional[str]]]: chain: List[Tuple[str, Optional[str]]] = [] # Walk parent chain (by name) outward parent_name = meta.get("parent") while parent_name: pattern, full_name = _find_pattern_by_breadcrumb_name(parent_name) if not pattern: break view = pattern.callback vmeta = None cur = view while cur is not None and vmeta is None: vmeta = getattr(cur, "_breadcrumbs_meta", None) cur = getattr(cur, "__wrapped__", None) vmeta = vmeta or {} label = vmeta.get("label") or parent_name # Try to reverse parent URL (skip if args required) url: Optional[str] = None if full_name: try: url = reverse(full_name) except NoReverseMatch: url = None chain.insert(0, (label, url)) parent_name = vmeta.get("parent") # Add current at the end (no URL) chain.append((meta.get("label") or "", None)) return chain def breadcrumbs(*, label: str, name: Optional[str] = None, parent: Optional[str] = None): """Decorator to attach breadcrumb metadata and expose a computed trail. Usage: @breadcrumbs(label="Posts", name="posts") def posts_list(...): ... @breadcrumbs(label="Post Edit", parent="posts") def post_edit(...): ... """ def _decorator(view_func): meta = {"label": label, "name": name, "parent": parent} @wraps(view_func) def _wrapped(request, *args, **kwargs): # Expose breadcrumbs via request for templates/tags to consume request._breadcrumbs = _build_breadcrumb_chain(meta) return view_func(request, *args, **kwargs) # Make metadata discoverable on the resolved callback setattr(_wrapped, "_breadcrumbs_meta", meta) return _wrapped return _decorator @staff_member_required @breadcrumbs(label="Posts", name="posts") @public_route(label="CMS Posts", order=70, icon="document-text") def posts_list(request): if request.method == "POST": form = PostForm(request.POST, request.FILES) if form.is_valid(): obj = form.save(commit=False) # Scope to current organization if available org = getattr(request, "org", None) if org is not None: obj.organization = org obj.save() messages.success(request, "Post created.") return redirect("cms:posts_list") else: messages.error(request, "Please correct the errors below.") else: form = PostForm() # Restrict category choices to current organization org = getattr(request, "org", None) if org is not None: try: form.fields["category"].queryset = PostCategory.objects.filter(organization=org) except Exception: pass qs = Post.objects.select_related("category", "author").all().order_by("-published_at", "-created_at") if org is not None: qs = qs.filter(organization=org) post_filter = PostFilter(request.GET, queryset=qs) # Also scope the filter's category queryset if org is not None: try: post_filter.form.fields["category"].queryset = PostCategory.objects.filter(organization=org) except Exception: pass paginator = Paginator(post_filter.qs, 10) page = request.GET.get("page") try: page_obj = paginator.page(page) except PageNotAnInteger: page_obj = paginator.page(1) except EmptyPage: page_obj = paginator.page(paginator.num_pages) context = { "nav": _nav_items(), "posts": page_obj.object_list, "page_obj": page_obj, "form": form, "filter": post_filter, } return render(request, "cms/posts_list.html", context) @staff_member_required @breadcrumbs(label="Post Edit", parent="posts") def post_edit(request, pk: int): org = getattr(request, "org", None) base_qs = Post.objects.all() if org is not None: base_qs = base_qs.filter(organization=org) item = get_object_or_404(base_qs, pk=pk) if request.method == "POST": form = PostForm(request.POST, request.FILES, instance=item) if form.is_valid(): obj = form.save(commit=False) org = getattr(request, "org", None) if org is not None: obj.organization = org obj.save() messages.success(request, "Post updated.") return redirect("cms:post_edit", pk=item.pk) else: form = PostForm(instance=item) # Restrict category choices org = getattr(request, "org", None) if org is not None: try: form.fields["category"].queryset = PostCategory.objects.filter(organization=org) except Exception: pass context = { "nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("cms:posts_list"), } return render(request, "cms/post_form.html", context) @staff_member_required @require_POST def post_delete(request, pk: int): org = getattr(request, "org", None) base_qs = Post.objects.all() if org is not None: base_qs = base_qs.filter(organization=org) item = get_object_or_404(base_qs, pk=pk) item.delete() messages.success(request, "Post deleted.") return redirect("cms:posts_list") @staff_member_required @public_route(label="CMS Categories", order=75, icon="tag") def categories_list(request): if request.method == "POST": form = PostCategoryForm(request.POST) if form.is_valid(): obj = form.save(commit=False) org = getattr(request, "org", None) if org is not None: obj.organization = org obj.save() messages.success(request, "Category created.") return redirect("cms:categories_list") else: messages.error(request, "Please correct the errors below.") else: form = PostCategoryForm() q = (request.GET.get("q") or "").strip() qs = PostCategory.objects.all().order_by("name") org = getattr(request, "org", None) if org is not None: qs = qs.filter(organization=org) if q: qs = qs.filter(name__icontains=q) paginator = Paginator(qs, 15) page = request.GET.get("page") try: page_obj = paginator.page(page) except PageNotAnInteger: page_obj = paginator.page(1) except EmptyPage: page_obj = paginator.page(paginator.num_pages) context = { "nav": _nav_items(), "categories": qs, "page_obj": page_obj, "form": form, "query": q, } return render(request, "cms/categories_list.html", context) @staff_member_required def category_edit(request, pk: int): org = getattr(request, "org", None) base_qs = PostCategory.objects.all() if org is not None: base_qs = base_qs.filter(organization=org) item = get_object_or_404(base_qs, pk=pk) if request.method == "POST": form = PostCategoryForm(request.POST, instance=item) if form.is_valid(): obj = form.save(commit=False) org = getattr(request, "org", None) if org is not None: obj.organization = org obj.save() messages.success(request, "Category updated.") return redirect("cms:category_edit", pk=item.pk) else: form = PostCategoryForm(instance=item) # Restrict parent choices org = getattr(request, "org", None) if org is not None: try: form.fields["parent"].queryset = PostCategory.objects.filter(organization=org) except Exception: pass context = { "nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("cms:categories_list"), } return render(request, "cms/category_form.html", context) @staff_member_required @require_POST def category_delete(request, pk: int): org = getattr(request, "org", None) base_qs = PostCategory.objects.all() if org is not None: base_qs = base_qs.filter(organization=org) item = get_object_or_404(base_qs, pk=pk) item.delete() messages.success(request, "Category deleted.") return redirect("cms:categories_list")