django-var-cms 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. django_var_cms-1.0.0/PKG-INFO +16 -0
  2. django_var_cms-1.0.0/pyproject.toml +34 -0
  3. django_var_cms-1.0.0/var_cms/__init__.py +1 -0
  4. django_var_cms-1.0.0/var_cms/apps.py +19 -0
  5. django_var_cms-1.0.0/var_cms/media_utils/__init__.py +0 -0
  6. django_var_cms-1.0.0/var_cms/media_utils/convert.py +140 -0
  7. django_var_cms-1.0.0/var_cms/media_utils/crop.py +91 -0
  8. django_var_cms-1.0.0/var_cms/permissions.py +123 -0
  9. django_var_cms-1.0.0/var_cms/registry.py +834 -0
  10. django_var_cms-1.0.0/var_cms/static/var_cms/var.png +0 -0
  11. django_var_cms-1.0.0/var_cms/templates/var_cms/about.html +79 -0
  12. django_var_cms-1.0.0/var_cms/templates/var_cms/base.html +1060 -0
  13. django_var_cms-1.0.0/var_cms/templates/var_cms/change_password.html +61 -0
  14. django_var_cms-1.0.0/var_cms/templates/var_cms/delete.html +30 -0
  15. django_var_cms-1.0.0/var_cms/templates/var_cms/detail.html +41 -0
  16. django_var_cms-1.0.0/var_cms/templates/var_cms/forgot_password.html +255 -0
  17. django_var_cms-1.0.0/var_cms/templates/var_cms/forgot_password_verify.html +271 -0
  18. django_var_cms-1.0.0/var_cms/templates/var_cms/form.html +235 -0
  19. django_var_cms-1.0.0/var_cms/templates/var_cms/index.html +198 -0
  20. django_var_cms-1.0.0/var_cms/templates/var_cms/list.html +175 -0
  21. django_var_cms-1.0.0/var_cms/templates/var_cms/login.html +248 -0
  22. django_var_cms-1.0.0/var_cms/templates/var_cms/otp_verify.html +234 -0
  23. django_var_cms-1.0.0/var_cms/templatetags/__init__.py +0 -0
  24. django_var_cms-1.0.0/var_cms/templatetags/cms_tags.py +56 -0
  25. django_var_cms-1.0.0/var_cms/templatetags/var_cms_tags.py +94 -0
  26. django_var_cms-1.0.0/var_cms/urls.py +5 -0
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-var-cms
3
+ Version: 1.0.0
4
+ Summary: A modern, role-based Django CMS registry with rich media previews
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: django>=5.0
7
+ Requires-Dist: pillow>=10.0
8
+ Requires-Dist: whitenoise>=6.6
9
+ Provides-Extra: dev
10
+ Requires-Dist: django-debug-toolbar>=4.0; extra == 'dev'
11
+ Provides-Extra: geo
12
+ Requires-Dist: django[gis]>=5.0; extra == 'geo'
13
+ Provides-Extra: pdf
14
+ Requires-Dist: pdf2image>=1.16; extra == 'pdf'
15
+ Provides-Extra: tailwind
16
+ Requires-Dist: django-tailwind-cli>=2.0; extra == 'tailwind'
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "django-var-cms"
3
+ version = "1.0.0"
4
+ description = "A modern, role-based Django CMS registry with rich media previews"
5
+ requires-python = ">=3.11"
6
+
7
+ dependencies = [
8
+ "django>=5.0",
9
+ "pillow>=10.0",
10
+ "whitenoise>=6.6",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ tailwind = ["django-tailwind-cli>=2.0"]
15
+ geo = ["django[gis]>=5.0"]
16
+ pdf = ["pdf2image>=1.16"]
17
+ dev = ["django-debug-toolbar>=4.0"]
18
+
19
+ [tool.hatch.build.targets.sdist]
20
+ include = [
21
+ "/var_cms",
22
+ ]
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["var_cms"]
26
+
27
+ [dependency-groups]
28
+ dev = ["django-debug-toolbar>=4.0"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+
@@ -0,0 +1 @@
1
+ default_app_config = "var_cms.apps.VarCmsConfig"
@@ -0,0 +1,19 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class VarCmsConfig(AppConfig):
5
+ name = "var_cms"
6
+ verbose_name = "VAR CMS"
7
+
8
+ def ready(self):
9
+ from django.apps import apps
10
+ for app_config in apps.get_app_configs():
11
+ try:
12
+ __import__(f"{app_config.name}.var_cms_admin")
13
+ except ModuleNotFoundError:
14
+ pass
15
+ except Exception as e:
16
+ import logging
17
+ logging.getLogger(__name__).warning(
18
+ "var_cms_admin import error in %s: %s", app_config.name, e
19
+ )
File without changes
@@ -0,0 +1,140 @@
1
+ """
2
+ var_cms/media_utils/convert.py
3
+ ==============================
4
+ File format converter.
5
+
6
+ Supported conversions:
7
+ Images — jpeg ↔ png ↔ webp ↔ bmp ↔ tiff ↔ gif
8
+ Audio — mp3 ↔ wav ↔ ogg ↔ flac ↔ aac (requires ffmpeg)
9
+ Video — mp4 ↔ webm ↔ avi ↔ mov (requires ffmpeg)
10
+ PDF → images (page-by-page PNG) (requires pdf2image + poppler)
11
+ """
12
+
13
+ import io
14
+ import os
15
+ import subprocess
16
+ import tempfile
17
+ import uuid
18
+
19
+ from django.conf import settings
20
+ from django.core.files.base import ContentFile
21
+ from django.core.files.storage import default_storage
22
+ from django.http import JsonResponse
23
+
24
+ IMAGE_FORMATS = {"jpeg", "jpg", "png", "webp", "bmp", "tiff", "gif"}
25
+ AUDIO_FORMATS = {"mp3", "wav", "ogg", "flac", "aac", "m4a"}
26
+ VIDEO_FORMATS = {"mp4", "webm", "avi", "mov", "mkv"}
27
+ DOC_FORMATS = {"pdf"}
28
+
29
+
30
+ def _abs(file_path: str) -> str:
31
+ clean = file_path.lstrip("/").replace(settings.MEDIA_URL.lstrip("/"), "", 1)
32
+ return os.path.join(settings.MEDIA_ROOT, clean)
33
+
34
+
35
+ def _save(data: bytes, ext: str) -> dict:
36
+ name = f"converted/{uuid.uuid4().hex[:12]}.{ext}"
37
+ path = default_storage.save(name, ContentFile(data))
38
+ return {"url": default_storage.url(path), "path": path}
39
+
40
+
41
+ def handle_convert(request):
42
+ """
43
+ POST params:
44
+ file_path — relative media path of source file
45
+ target_fmt — desired output extension (e.g. "webp", "mp3", "mp4")
46
+ """
47
+ file_path = request.POST.get("file_path", "")
48
+ target_fmt = request.POST.get("target_fmt", "").lower().lstrip(".")
49
+
50
+ if not file_path or not target_fmt:
51
+ return JsonResponse({"error": "file_path and target_fmt required"}, status=400)
52
+
53
+ abs_path = _abs(file_path)
54
+ if not os.path.exists(abs_path):
55
+ return JsonResponse({"error": "File not found"}, status=404)
56
+
57
+ src_ext = abs_path.rsplit(".", 1)[-1].lower()
58
+
59
+ # ── Image conversion ──────────────────────────────────────────────────────
60
+ if src_ext in IMAGE_FORMATS and target_fmt in IMAGE_FORMATS:
61
+ return _convert_image(abs_path, target_fmt)
62
+
63
+ # ── Audio / Video conversion (ffmpeg) ─────────────────────────────────────
64
+ if src_ext in (AUDIO_FORMATS | VIDEO_FORMATS) or target_fmt in (AUDIO_FORMATS | VIDEO_FORMATS):
65
+ return _convert_ffmpeg(abs_path, target_fmt)
66
+
67
+ # ── PDF → images ──────────────────────────────────────────────────────────
68
+ if src_ext == "pdf" and target_fmt in ("png", "jpg", "jpeg"):
69
+ return _convert_pdf(abs_path, target_fmt)
70
+
71
+ return JsonResponse({"error": f"Unsupported conversion: {src_ext} → {target_fmt}"}, status=400)
72
+
73
+
74
+ def _convert_image(abs_path: str, target_fmt: str) -> JsonResponse:
75
+ try:
76
+ from PIL import Image
77
+ except ImportError:
78
+ return JsonResponse({"error": "Pillow not installed"}, status=500)
79
+
80
+ fmt_map = {"jpg": "JPEG", "jpeg": "JPEG", "png": "PNG",
81
+ "webp": "WEBP", "bmp": "BMP", "tiff": "TIFF", "gif": "GIF"}
82
+ pil_fmt = fmt_map.get(target_fmt, target_fmt.upper())
83
+
84
+ img = Image.open(abs_path)
85
+ if pil_fmt == "JPEG" and img.mode in ("RGBA", "P"):
86
+ img = img.convert("RGB")
87
+
88
+ buf = io.BytesIO()
89
+ img.save(buf, format=pil_fmt, quality=90)
90
+ buf.seek(0)
91
+ ext = "jpg" if target_fmt in ("jpg", "jpeg") else target_fmt
92
+ return JsonResponse(_save(buf.read(), ext) | {"type": "image"})
93
+
94
+
95
+ def _convert_ffmpeg(abs_path: str, target_fmt: str) -> JsonResponse:
96
+ if subprocess.run(["which", "ffmpeg"], capture_output=True).returncode != 0:
97
+ return JsonResponse({
98
+ "error": "ffmpeg not found. Install with: sudo apt install ffmpeg",
99
+ "install_hint": "sudo apt install ffmpeg"
100
+ }, status=500)
101
+
102
+ with tempfile.NamedTemporaryFile(suffix=f".{target_fmt}", delete=False) as tmp:
103
+ out_path = tmp.name
104
+
105
+ try:
106
+ cmd = ["ffmpeg", "-y", "-i", abs_path, out_path]
107
+ result = subprocess.run(cmd, capture_output=True, timeout=120)
108
+ if result.returncode != 0:
109
+ err = result.stderr.decode(errors="replace")[-500:]
110
+ return JsonResponse({"error": f"ffmpeg error: {err}"}, status=500)
111
+
112
+ with open(out_path, "rb") as f:
113
+ data = f.read()
114
+
115
+ media_type = "audio" if target_fmt in AUDIO_FORMATS else "video"
116
+ return JsonResponse(_save(data, target_fmt) | {"type": media_type})
117
+ finally:
118
+ if os.path.exists(out_path):
119
+ os.unlink(out_path)
120
+
121
+
122
+ def _convert_pdf(abs_path: str, target_fmt: str) -> JsonResponse:
123
+ try:
124
+ from pdf2image import convert_from_path
125
+ except ImportError:
126
+ return JsonResponse({
127
+ "error": "pdf2image not installed. Run: uv add pdf2image",
128
+ "install_hint": "uv add pdf2image"
129
+ }, status=500)
130
+
131
+ images = convert_from_path(abs_path, dpi=150)
132
+ urls = []
133
+ for i, img in enumerate(images):
134
+ buf = io.BytesIO()
135
+ img.save(buf, format="PNG")
136
+ buf.seek(0)
137
+ result = _save(buf.read(), "png")
138
+ urls.append(result["url"])
139
+
140
+ return JsonResponse({"type": "pdf_pages", "pages": urls, "count": len(urls)})
@@ -0,0 +1,91 @@
1
+ """
2
+ var_cms/media_utils/crop.py
3
+ ===========================
4
+ Image crop handler — receives crop coordinates from the frontend Cropper.js
5
+ widget and returns a new cropped image saved to MEDIA_ROOT.
6
+ """
7
+
8
+ import io
9
+ import os
10
+ import uuid
11
+
12
+ from django.conf import settings
13
+ from django.core.files.base import ContentFile
14
+ from django.core.files.storage import default_storage
15
+ from django.http import JsonResponse
16
+
17
+
18
+ def handle_crop(request):
19
+ """
20
+ POST params:
21
+ file_path — relative media path of the source image
22
+ x, y, w, h — crop box in pixels
23
+ rotate — rotation degrees (optional, default 0)
24
+ scale_x — horizontal flip (-1 or 1)
25
+ scale_y — vertical flip (-1 or 1)
26
+ format — output format: jpeg | png | webp (default: same as source)
27
+ """
28
+ try:
29
+ from PIL import Image
30
+ except ImportError:
31
+ return JsonResponse({"error": "Pillow not installed. Run: uv add pillow"}, status=500)
32
+
33
+ file_path = request.POST.get("file_path", "")
34
+ if not file_path:
35
+ return JsonResponse({"error": "file_path required"}, status=400)
36
+
37
+ # Sanitise — must be inside MEDIA_ROOT
38
+ abs_path = os.path.join(settings.MEDIA_ROOT, file_path.lstrip("/").replace(settings.MEDIA_URL.lstrip("/"), "", 1))
39
+ if not os.path.exists(abs_path):
40
+ return JsonResponse({"error": "File not found"}, status=404)
41
+
42
+ try:
43
+ x = int(float(request.POST.get("x", 0)))
44
+ y = int(float(request.POST.get("y", 0)))
45
+ w = int(float(request.POST.get("w", 0)))
46
+ h = int(float(request.POST.get("h", 0)))
47
+ rotate = float(request.POST.get("rotate", 0))
48
+ scale_x = float(request.POST.get("scale_x", 1))
49
+ scale_y = float(request.POST.get("scale_y", 1))
50
+ out_fmt = request.POST.get("format", "").upper()
51
+ except (ValueError, TypeError) as e:
52
+ return JsonResponse({"error": f"Invalid params: {e}"}, status=400)
53
+
54
+ img = Image.open(abs_path).convert("RGBA")
55
+
56
+ # Flip
57
+ if scale_x == -1:
58
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
59
+ if scale_y == -1:
60
+ img = img.transpose(Image.FLIP_TOP_BOTTOM)
61
+
62
+ # Rotate (expand=True keeps full image)
63
+ if rotate:
64
+ img = img.rotate(-rotate, expand=True)
65
+
66
+ # Crop
67
+ if w > 0 and h > 0:
68
+ img = img.crop((x, y, x + w, y + h))
69
+
70
+ # Determine output format
71
+ orig_ext = abs_path.rsplit(".", 1)[-1].upper()
72
+ fmt_map = {"JPG": "JPEG", "JPEG": "JPEG", "PNG": "PNG", "WEBP": "WEBP"}
73
+ save_fmt = fmt_map.get(out_fmt) or fmt_map.get(orig_ext) or "PNG"
74
+
75
+ # JPEG can't have alpha
76
+ if save_fmt == "JPEG":
77
+ img = img.convert("RGB")
78
+
79
+ ext_map = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
80
+ ext = ext_map.get(save_fmt, "png")
81
+
82
+ buf = io.BytesIO()
83
+ img.save(buf, format=save_fmt, quality=90)
84
+ buf.seek(0)
85
+
86
+ # Save alongside originals
87
+ new_name = f"crops/{uuid.uuid4().hex[:12]}.{ext}"
88
+ saved_path = default_storage.save(new_name, ContentFile(buf.read()))
89
+ url = default_storage.url(saved_path)
90
+
91
+ return JsonResponse({"url": url, "path": saved_path, "format": save_fmt, "size": [img.width, img.height]})
@@ -0,0 +1,123 @@
1
+ """
2
+ var_cms/permissions.py
3
+ ======================
4
+ Role-based permission system for django-var-cms.
5
+
6
+ RolePermission — defines what a named role can do
7
+ GroupPermission — same but matches Django auth groups
8
+ UserPermission — per-user overrides (highest priority)
9
+
10
+ Resolution order: UserPermission > GroupPermission > RolePermission > deny
11
+ """
12
+
13
+ from __future__ import annotations
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, List, Optional, Union
16
+
17
+
18
+ ACTIONS = ("add", "list", "view", "edit", "delete")
19
+
20
+
21
+ @dataclass
22
+ class RolePermission:
23
+ """
24
+ Map a role name (string) to a set of allowed actions.
25
+
26
+ The role name is matched against:
27
+ 1. "superuser" — request.user.is_superuser
28
+ 2. Django group names — request.user.groups
29
+ 3. Custom role logic — override VarCMSModelAdmin._get_user_role()
30
+ """
31
+ role: str
32
+ add: bool = False
33
+ list: bool = True
34
+ view: bool = True
35
+ edit: bool = False
36
+ delete: bool = False
37
+
38
+ def allows(self, action: str) -> bool:
39
+ return bool(getattr(self, action, False))
40
+
41
+
42
+ @dataclass
43
+ class GroupPermission(RolePermission):
44
+ """Alias — identical to RolePermission, role matches a Django group name."""
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class UserPermission:
50
+ """
51
+ Per-user permission override (matched by username or user pk).
52
+ Takes priority over all role/group permissions.
53
+ """
54
+ username: str
55
+ add: bool = False
56
+ list: bool = True
57
+ view: bool = True
58
+ edit: bool = False
59
+ delete: bool = False
60
+
61
+ def allows(self, action: str) -> bool:
62
+ return bool(getattr(self, action, False))
63
+
64
+
65
+ def resolve_permission(
66
+ permissions: List[Union[RolePermission, UserPermission]],
67
+ role: str,
68
+ action: str,
69
+ username: str = "",
70
+ ) -> bool:
71
+ """
72
+ Walk the permission list and return True if allowed.
73
+ UserPermission (matched by username) takes priority.
74
+ """
75
+ if action not in ACTIONS:
76
+ return False
77
+
78
+ # 1. User-level overrides first
79
+ for perm in permissions:
80
+ if isinstance(perm, UserPermission) and perm.username == username:
81
+ return perm.allows(action)
82
+
83
+ # 2. Role / group match
84
+ for perm in permissions:
85
+ if isinstance(perm, RolePermission) and perm.role == role:
86
+ return perm.allows(action)
87
+
88
+ # 3. Default deny
89
+ return False
90
+
91
+
92
+ def resolve_editable_fields(
93
+ role_editable_fields: Dict[str, Union[List[str], str]],
94
+ role: str,
95
+ ) -> Union[List[str], str]:
96
+ """
97
+ Return the editable fields for a given role.
98
+ Falls back to "__all__" for superuser if not explicitly set.
99
+ """
100
+ if role in role_editable_fields:
101
+ return role_editable_fields[role]
102
+ if role == "superuser":
103
+ return "__all__"
104
+ # Check wildcard
105
+ if "*" in role_editable_fields:
106
+ return role_editable_fields["*"]
107
+ return [] # deny all edits for unknown roles
108
+
109
+
110
+ def permission_summary(permissions: List[RolePermission]) -> List[Dict]:
111
+ """Return a list of dicts for rendering a permission table in templates."""
112
+ return [
113
+ {
114
+ "role": p.role,
115
+ "add": p.add,
116
+ "list": p.list,
117
+ "view": p.view,
118
+ "edit": p.edit,
119
+ "delete": p.delete,
120
+ }
121
+ for p in permissions
122
+ if isinstance(p, (RolePermission, UserPermission))
123
+ ]