django-mosaic 0.1.0__py3-none-any.whl
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.
- django_mosaic/__init__.py +0 -0
- django_mosaic/admin.py +67 -0
- django_mosaic/apps.py +5 -0
- django_mosaic/feeds.py +25 -0
- django_mosaic/management/__init__.py +0 -0
- django_mosaic/management/commands/__init__.py +0 -0
- django_mosaic/management/commands/import.py +71 -0
- django_mosaic/migrations/0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together.py +124 -0
- django_mosaic/migrations/0009_alter_post_published_at.py +21 -0
- django_mosaic/migrations/0010_alter_post_published_at_alter_post_slug_and_more.py +28 -0
- django_mosaic/migrations/0011_remove_post_is_draft_post_is_published_and_more.py +37 -0
- django_mosaic/migrations/0012_author_post_author.py +38 -0
- django_mosaic/migrations/0013_add_author_to_post.py +26 -0
- django_mosaic/migrations/0014_contentimage.py +43 -0
- django_mosaic/migrations/0015_contentimage_post.py +22 -0
- django_mosaic/migrations/0016_alter_contentimage_image_alter_contentimage_thumb.py +25 -0
- django_mosaic/migrations/__init__.py +0 -0
- django_mosaic/models.py +162 -0
- django_mosaic/templates/base.html +26 -0
- django_mosaic/templates/home.html +24 -0
- django_mosaic/templates/post-detail-include.html +1 -0
- django_mosaic/templates/post-detail.html +25 -0
- django_mosaic/templates/post-list.html +10 -0
- django_mosaic/templates/tag-detail.html +8 -0
- django_mosaic/tests/post-1-introduction-to-lorem.md +45 -0
- django_mosaic/tests/post-2-advanced-techniques.md +91 -0
- django_mosaic/tests.py +1 -0
- django_mosaic/urls.py +25 -0
- django_mosaic/views.py +49 -0
- django_mosaic-0.1.0.dist-info/METADATA +122 -0
- django_mosaic-0.1.0.dist-info/RECORD +32 -0
- django_mosaic-0.1.0.dist-info/WHEEL +4 -0
|
File without changes
|
django_mosaic/admin.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django import forms
|
|
4
|
+
from django.utils.html import format_html
|
|
5
|
+
from mosaic.models import Post, Tag, ContentImage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContentImageInlineAdmin(admin.TabularInline):
|
|
9
|
+
model = ContentImage
|
|
10
|
+
readonly_fields = ["thumb", "thumbnail_preview", "copy_markdown_button"]
|
|
11
|
+
fields = ["image", "thumbnail_preview", "caption", "alt", "copy_markdown_button"]
|
|
12
|
+
|
|
13
|
+
def thumbnail_preview(self, obj):
|
|
14
|
+
if obj.thumb:
|
|
15
|
+
return format_html(
|
|
16
|
+
'<img src="{}" style="max-height: 100px; max-width: 200px;" />',
|
|
17
|
+
obj.thumb.url,
|
|
18
|
+
)
|
|
19
|
+
return "No thumbnail"
|
|
20
|
+
|
|
21
|
+
thumbnail_preview.short_description = "Preview"
|
|
22
|
+
|
|
23
|
+
def copy_markdown_button(self, obj):
|
|
24
|
+
if obj.pk:
|
|
25
|
+
markdown_text = obj.markdown()
|
|
26
|
+
return format_html(
|
|
27
|
+
'<button type="button" class="button" '
|
|
28
|
+
'onclick="navigator.clipboard.writeText(this.dataset.markdown).then(() => '
|
|
29
|
+
"{{ this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy Markdown', 1500) }})\""
|
|
30
|
+
'data-markdown="{}">'
|
|
31
|
+
"Copy Markdown"
|
|
32
|
+
"</button>",
|
|
33
|
+
markdown_text.replace('"', """),
|
|
34
|
+
)
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
copy_markdown_button.short_description = "Markdown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PostAdmin(admin.ModelAdmin):
|
|
41
|
+
readonly_fields = ["created_at"]
|
|
42
|
+
list_display = ["title", "published_at", "namespace", "is_published", "created_at"]
|
|
43
|
+
list_filter = ["is_published", "namespace", "tags", "published_at"]
|
|
44
|
+
|
|
45
|
+
formfield_overrides = {
|
|
46
|
+
models.TextField: {
|
|
47
|
+
"widget": forms.Textarea(
|
|
48
|
+
attrs={"rows": "20", "style": "max-height: none; width: 100%"}
|
|
49
|
+
)
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
inlines = [ContentImageInlineAdmin]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ContentImageAdmin(admin.ModelAdmin):
|
|
57
|
+
readonly_fields = ["image", "thumb"]
|
|
58
|
+
list_display = ["alt", "caption", "post", "post__created_at"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TagAdmin(admin.ModelAdmin):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
admin.site.register(Post, PostAdmin)
|
|
66
|
+
admin.site.register(Tag, TagAdmin)
|
|
67
|
+
admin.site.register(ContentImage, ContentImageAdmin)
|
django_mosaic/apps.py
ADDED
django_mosaic/feeds.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.contrib.syndication.views import Feed
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
from mosaic.models import Post, Namespace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PostFeed(Feed):
|
|
8
|
+
title = settings.CONSTANTS["site"]["title"]
|
|
9
|
+
link = "/"
|
|
10
|
+
description = settings.CONSTANTS["site"]["description"]
|
|
11
|
+
|
|
12
|
+
def get_object(self, request, namespace):
|
|
13
|
+
return Namespace.objects.get(name=namespace)
|
|
14
|
+
|
|
15
|
+
def items(self, obj):
|
|
16
|
+
return Post.objects.filter(namespace=obj)
|
|
17
|
+
|
|
18
|
+
def item_title(self, item):
|
|
19
|
+
return item.title
|
|
20
|
+
|
|
21
|
+
def item_description(self, item):
|
|
22
|
+
return item.content
|
|
23
|
+
|
|
24
|
+
def item_pubdate(self, item):
|
|
25
|
+
return item.published_at
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
import dateutil
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from django.core.management.base import BaseCommand
|
|
7
|
+
from django.utils.text import slugify
|
|
8
|
+
from mosaic.models import Post, Tag, Namespace
|
|
9
|
+
|
|
10
|
+
EXPECTED_KEYWORDS = ["title", "date", "draft"]
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(BaseCommand):
|
|
16
|
+
help = "Imports markdown posts with a yaml header"
|
|
17
|
+
|
|
18
|
+
def add_arguments(self, parser):
|
|
19
|
+
parser.add_argument("path", type=Path)
|
|
20
|
+
parser.add_argument("category", type=str)
|
|
21
|
+
|
|
22
|
+
def handle(self, *args, **options):
|
|
23
|
+
ns = Namespace.objects.get(name=options["category"])
|
|
24
|
+
|
|
25
|
+
for file in options["path"].glob("**/*.md"):
|
|
26
|
+
logger.info(f"Importing {file}")
|
|
27
|
+
with open(file, "r") as f:
|
|
28
|
+
try:
|
|
29
|
+
file_content = f.read()
|
|
30
|
+
_, header, content = file_content.split("---", maxsplit=2)
|
|
31
|
+
|
|
32
|
+
header = yaml.load(header, Loader=yaml.BaseLoader)
|
|
33
|
+
|
|
34
|
+
if not all(ek in header.keys() for ek in EXPECTED_KEYWORDS):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Could not find all expected keywords in post metadata. Expected {EXPECTED_KEYWORDS}, found {header.keys()}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
slug = header.get("slug", slugify(header["title"]))
|
|
40
|
+
|
|
41
|
+
tags = []
|
|
42
|
+
header_tags = [t.strip() for t in header.get("tags", "").split(",") if t]
|
|
43
|
+
header_categories = [c.strip() for c in header.get("categories", "").split(",") if c]
|
|
44
|
+
header_tags.extend(header_categories)
|
|
45
|
+
|
|
46
|
+
if header_tags:
|
|
47
|
+
for t in header_tags:
|
|
48
|
+
if not isinstance(t, str):
|
|
49
|
+
logger.warning(
|
|
50
|
+
f"Could not process tag {t}, not a string"
|
|
51
|
+
)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
t, _ = Tag.objects.get_or_create(name=t, namespace=ns)
|
|
55
|
+
tags.append(t)
|
|
56
|
+
|
|
57
|
+
post = Post(
|
|
58
|
+
title=header["title"],
|
|
59
|
+
is_published=not header["draft"],
|
|
60
|
+
published_at=dateutil.parser.parse(header["date"]),
|
|
61
|
+
slug=slug,
|
|
62
|
+
namespace=ns,
|
|
63
|
+
summary=header.get("description", ""),
|
|
64
|
+
content=content,
|
|
65
|
+
)
|
|
66
|
+
post.save()
|
|
67
|
+
for t in tags:
|
|
68
|
+
post.tags.add(t)
|
|
69
|
+
logger.info(f"Created post {post} with tags {tags}")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Could not import {file}: {e}", exc_info=True)
|
django_mosaic/migrations/0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-22 13:17
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import django.utils.timezone
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_default_namespace(apps, schema_editor):
|
|
9
|
+
Namespace = apps.get_model("mosaic", "Namespace")
|
|
10
|
+
Namespace.objects.create(name="public")
|
|
11
|
+
Namespace.objects.create(name="private")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Migration(migrations.Migration):
|
|
15
|
+
|
|
16
|
+
replaces = [
|
|
17
|
+
("mosaic", "0001_initial"),
|
|
18
|
+
("mosaic", "0002_remove_post_category_alter_post_options_and_more"),
|
|
19
|
+
("mosaic", "0003_post__summary"),
|
|
20
|
+
("mosaic", "0004_alter_post__summary"),
|
|
21
|
+
("mosaic", "0005_tag_is_public"),
|
|
22
|
+
(
|
|
23
|
+
"mosaic",
|
|
24
|
+
"0006_namespace_remove_post__summary_remove_post_is_public_and_more",
|
|
25
|
+
),
|
|
26
|
+
("mosaic", "0007_post_namespace_tag_namespace"),
|
|
27
|
+
("mosaic", "0008_alter_tag_name_alter_tag_unique_together"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
operations = [
|
|
33
|
+
migrations.CreateModel(
|
|
34
|
+
name="Tag",
|
|
35
|
+
fields=[
|
|
36
|
+
(
|
|
37
|
+
"id",
|
|
38
|
+
models.BigAutoField(
|
|
39
|
+
auto_created=True,
|
|
40
|
+
primary_key=True,
|
|
41
|
+
serialize=False,
|
|
42
|
+
verbose_name="ID",
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
("name", models.CharField(max_length=256, unique=True)),
|
|
46
|
+
],
|
|
47
|
+
),
|
|
48
|
+
migrations.CreateModel(
|
|
49
|
+
name="Namespace",
|
|
50
|
+
fields=[
|
|
51
|
+
(
|
|
52
|
+
"id",
|
|
53
|
+
models.BigAutoField(
|
|
54
|
+
auto_created=True,
|
|
55
|
+
primary_key=True,
|
|
56
|
+
serialize=False,
|
|
57
|
+
verbose_name="ID",
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
("name", models.SlugField(max_length=256, unique=True)),
|
|
61
|
+
],
|
|
62
|
+
),
|
|
63
|
+
migrations.RunPython(
|
|
64
|
+
code=add_default_namespace,
|
|
65
|
+
),
|
|
66
|
+
migrations.CreateModel(
|
|
67
|
+
name="Post",
|
|
68
|
+
fields=[
|
|
69
|
+
(
|
|
70
|
+
"id",
|
|
71
|
+
models.BigAutoField(
|
|
72
|
+
auto_created=True,
|
|
73
|
+
primary_key=True,
|
|
74
|
+
serialize=False,
|
|
75
|
+
verbose_name="ID",
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
("title", models.CharField(max_length=512, unique=True)),
|
|
79
|
+
("content", models.TextField()),
|
|
80
|
+
("slug", models.SlugField(max_length=256, unique=True)),
|
|
81
|
+
("is_draft", models.BooleanField(default=True)),
|
|
82
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
83
|
+
("changed_at", models.DateTimeField(auto_now=True)),
|
|
84
|
+
("tags", models.ManyToManyField(to="mosaic.tag")),
|
|
85
|
+
(
|
|
86
|
+
"published_at",
|
|
87
|
+
models.DateTimeField(blank=True, default=django.utils.timezone.now),
|
|
88
|
+
),
|
|
89
|
+
("summary", models.CharField(blank=True, max_length=1024)),
|
|
90
|
+
],
|
|
91
|
+
options={
|
|
92
|
+
"ordering": ["-published_at"],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
migrations.AddField(
|
|
96
|
+
model_name="post",
|
|
97
|
+
name="namespace",
|
|
98
|
+
field=models.ForeignKey(
|
|
99
|
+
default=1,
|
|
100
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
101
|
+
to="mosaic.namespace",
|
|
102
|
+
),
|
|
103
|
+
preserve_default=False,
|
|
104
|
+
),
|
|
105
|
+
migrations.AddField(
|
|
106
|
+
model_name="tag",
|
|
107
|
+
name="namespace",
|
|
108
|
+
field=models.ForeignKey(
|
|
109
|
+
default=1,
|
|
110
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
111
|
+
to="mosaic.namespace",
|
|
112
|
+
),
|
|
113
|
+
preserve_default=False,
|
|
114
|
+
),
|
|
115
|
+
migrations.AlterField(
|
|
116
|
+
model_name="tag",
|
|
117
|
+
name="name",
|
|
118
|
+
field=models.CharField(max_length=256),
|
|
119
|
+
),
|
|
120
|
+
migrations.AlterUniqueTogether(
|
|
121
|
+
name="tag",
|
|
122
|
+
unique_together={("name", "namespace")},
|
|
123
|
+
),
|
|
124
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-22 13:20
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
(
|
|
10
|
+
"mosaic",
|
|
11
|
+
"0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together",
|
|
12
|
+
),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.AlterField(
|
|
17
|
+
model_name="post",
|
|
18
|
+
name="published_at",
|
|
19
|
+
field=models.DateTimeField(blank=True),
|
|
20
|
+
),
|
|
21
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-22 18:00
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("mosaic", "0009_alter_post_published_at"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name="post",
|
|
15
|
+
name="published_at",
|
|
16
|
+
field=models.DateTimeField(blank=True, null=True),
|
|
17
|
+
),
|
|
18
|
+
migrations.AlterField(
|
|
19
|
+
model_name="post",
|
|
20
|
+
name="slug",
|
|
21
|
+
field=models.SlugField(blank=True, max_length=256, unique=True),
|
|
22
|
+
),
|
|
23
|
+
migrations.AlterField(
|
|
24
|
+
model_name="post",
|
|
25
|
+
name="tags",
|
|
26
|
+
field=models.ManyToManyField(blank=True, null=True, to="mosaic.tag"),
|
|
27
|
+
),
|
|
28
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-26 08:51
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def invert_is_draft(apps, schema):
|
|
7
|
+
Post = apps.get_model("mosaic", "Post")
|
|
8
|
+
drafts = Post.objects.filter(is_published=True)
|
|
9
|
+
public = Post.objects.filter(is_published=False)
|
|
10
|
+
drafts.update(is_published=False)
|
|
11
|
+
public.update(is_published=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Migration(migrations.Migration):
|
|
15
|
+
|
|
16
|
+
dependencies = [
|
|
17
|
+
("mosaic", "0010_alter_post_published_at_alter_post_slug_and_more"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
operations = [
|
|
21
|
+
migrations.RenameField(
|
|
22
|
+
model_name="post",
|
|
23
|
+
old_name="is_draft",
|
|
24
|
+
new_name="is_published"
|
|
25
|
+
),
|
|
26
|
+
migrations.AlterField(
|
|
27
|
+
model_name="post",
|
|
28
|
+
name="is_published",
|
|
29
|
+
field=models.BooleanField(default=False),
|
|
30
|
+
),
|
|
31
|
+
migrations.AlterField(
|
|
32
|
+
model_name="post",
|
|
33
|
+
name="tags",
|
|
34
|
+
field=models.ManyToManyField(blank=True, to="mosaic.tag"),
|
|
35
|
+
),
|
|
36
|
+
migrations.RunPython(invert_is_draft),
|
|
37
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-02-03 10:48
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
("mosaic", "0011_remove_post_is_draft_post_is_published_and_more"),
|
|
12
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.CreateModel(
|
|
17
|
+
name="Author",
|
|
18
|
+
fields=[
|
|
19
|
+
(
|
|
20
|
+
"id",
|
|
21
|
+
models.BigAutoField(
|
|
22
|
+
auto_created=True,
|
|
23
|
+
primary_key=True,
|
|
24
|
+
serialize=False,
|
|
25
|
+
verbose_name="ID",
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
("h_card", models.JSONField()),
|
|
29
|
+
(
|
|
30
|
+
"user",
|
|
31
|
+
models.OneToOneField(
|
|
32
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
33
|
+
to=settings.AUTH_USER_MODEL,
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-02-03 10:48
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
("mosaic", "0012_author_post_author"),
|
|
12
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.AddField(
|
|
17
|
+
model_name="post",
|
|
18
|
+
name="author",
|
|
19
|
+
field=models.ForeignKey(
|
|
20
|
+
default=1,
|
|
21
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
22
|
+
to="mosaic.author",
|
|
23
|
+
),
|
|
24
|
+
preserve_default=False,
|
|
25
|
+
),
|
|
26
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-02-03 15:39
|
|
2
|
+
|
|
3
|
+
import mosaic.models
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
("mosaic", "0013_add_author_to_post"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name="ContentImage",
|
|
16
|
+
fields=[
|
|
17
|
+
(
|
|
18
|
+
"id",
|
|
19
|
+
models.BigAutoField(
|
|
20
|
+
auto_created=True,
|
|
21
|
+
primary_key=True,
|
|
22
|
+
serialize=False,
|
|
23
|
+
verbose_name="ID",
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"image",
|
|
28
|
+
models.ImageField(upload_to="static/images"),
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"thumb",
|
|
32
|
+
models.ImageField(
|
|
33
|
+
blank=True,
|
|
34
|
+
editable=False,
|
|
35
|
+
null=True,
|
|
36
|
+
upload_to="static/images",
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
("caption", models.CharField(blank=True, default="", max_length=2048)),
|
|
40
|
+
("alt", models.CharField(blank=True, default="", max_length=2048)),
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-02-03 15:45
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
("mosaic", "0014_contentimage"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name="contentimage",
|
|
16
|
+
name="post",
|
|
17
|
+
field=models.ForeignKey(
|
|
18
|
+
default=1, on_delete=django.db.models.deletion.CASCADE, to="mosaic.post"
|
|
19
|
+
),
|
|
20
|
+
preserve_default=False,
|
|
21
|
+
),
|
|
22
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-02-03 16:10
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("mosaic", "0015_contentimage_post"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name="contentimage",
|
|
15
|
+
name="image",
|
|
16
|
+
field=models.ImageField(upload_to="content/images/"),
|
|
17
|
+
),
|
|
18
|
+
migrations.AlterField(
|
|
19
|
+
model_name="contentimage",
|
|
20
|
+
name="thumb",
|
|
21
|
+
field=models.ImageField(
|
|
22
|
+
blank=True, editable=False, null=True, upload_to="content/images/"
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
]
|
|
File without changes
|
django_mosaic/models.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import bleach
|
|
2
|
+
import markdown
|
|
3
|
+
import secrets
|
|
4
|
+
from PIL import Image
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import django.utils.timezone
|
|
10
|
+
from django.utils.text import slugify
|
|
11
|
+
from django.urls import reverse
|
|
12
|
+
from django.db import models
|
|
13
|
+
from django.contrib.auth.models import User
|
|
14
|
+
from django.core.files.base import ContentFile
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("mosaic")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Namespace(models.Model):
|
|
20
|
+
name = models.SlugField(max_length=256, unique=True, blank=False, null=False)
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return self.name
|
|
24
|
+
|
|
25
|
+
def __repr__(self):
|
|
26
|
+
return f"<Namespace {self.name}>"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Author(models.Model):
|
|
30
|
+
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
|
31
|
+
h_card = models.JSONField()
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return self.user.username
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return f"<Author {self.user.username}>"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ContentImage(models.Model):
|
|
41
|
+
image = models.ImageField(upload_to="content/images/")
|
|
42
|
+
thumb = models.ImageField(
|
|
43
|
+
upload_to="content/images/", blank=True, null=True, editable=False
|
|
44
|
+
)
|
|
45
|
+
caption = models.CharField(max_length=2048, null=False, blank=True, default="")
|
|
46
|
+
alt = models.CharField(max_length=2048, null=False, blank=True, default="")
|
|
47
|
+
post = models.ForeignKey("Post", on_delete=models.CASCADE)
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return "Image"
|
|
51
|
+
|
|
52
|
+
def __repr__(self):
|
|
53
|
+
return f"<Image {self.image} [{self.alt[:50]}]>"
|
|
54
|
+
|
|
55
|
+
def save(self, *args, **kwargs):
|
|
56
|
+
# Only generate thumbnail on creation
|
|
57
|
+
if not self.pk and self.image:
|
|
58
|
+
try:
|
|
59
|
+
# Generate random filename
|
|
60
|
+
ext = self.image.name.split(".")[-1]
|
|
61
|
+
random_name = secrets.token_hex(16)
|
|
62
|
+
new_filename = f"{random_name}.{ext}"
|
|
63
|
+
|
|
64
|
+
# Rename the image file
|
|
65
|
+
self.image.name = new_filename
|
|
66
|
+
|
|
67
|
+
# Generate thumbnail
|
|
68
|
+
img = Image.open(self.image.file)
|
|
69
|
+
img.thumbnail((600, 600), Image.Resampling.LANCZOS)
|
|
70
|
+
|
|
71
|
+
thumb_io = BytesIO()
|
|
72
|
+
img.save(thumb_io, format="PNG", quality=85)
|
|
73
|
+
thumb_io.seek(0)
|
|
74
|
+
|
|
75
|
+
# Save thumbnail with _thumb suffix
|
|
76
|
+
thumb_filename = f"{random_name}_thumb.{ext}"
|
|
77
|
+
self.thumb.save(
|
|
78
|
+
thumb_filename, ContentFile(thumb_io.read()), save=False
|
|
79
|
+
)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.warning(f"Failed to create thumbnail: {e}")
|
|
82
|
+
|
|
83
|
+
super().save(*args, **kwargs)
|
|
84
|
+
|
|
85
|
+
def markdown(self):
|
|
86
|
+
if self.thumb:
|
|
87
|
+
thumb = self.thumb
|
|
88
|
+
else:
|
|
89
|
+
thumb = self.image
|
|
90
|
+
|
|
91
|
+
if self.caption:
|
|
92
|
+
return f"<figure><a href='{self.image.url}'><img src='{thumb.url}' alt='{self.alt}'></a><figcaption>{self.caption}</figcaption></figure>"
|
|
93
|
+
return (
|
|
94
|
+
f"<a href='{self.image.url}'><img src='{thumb.url}' alt='{self.alt}'></a>"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Post(models.Model):
|
|
99
|
+
author = models.ForeignKey(Author, on_delete=models.PROTECT)
|
|
100
|
+
title = models.CharField(max_length=512, blank=False, null=False, unique=True)
|
|
101
|
+
content = models.TextField()
|
|
102
|
+
slug = models.SlugField(max_length=256, blank=True, null=False, unique=True)
|
|
103
|
+
summary = models.CharField(max_length=1024, null=False, blank=True)
|
|
104
|
+
|
|
105
|
+
namespace = models.ForeignKey(
|
|
106
|
+
"Namespace", on_delete=models.PROTECT, blank=False, null=False
|
|
107
|
+
)
|
|
108
|
+
is_published = models.BooleanField(default=False, blank=False, null=False)
|
|
109
|
+
|
|
110
|
+
tags = models.ManyToManyField("Tag", blank=True)
|
|
111
|
+
|
|
112
|
+
created_at = models.DateTimeField(auto_now_add=True, blank=False, null=False)
|
|
113
|
+
published_at = models.DateTimeField(blank=True, null=True)
|
|
114
|
+
changed_at = models.DateTimeField(auto_now=True, blank=False, null=False)
|
|
115
|
+
|
|
116
|
+
def save(self, *args, **kwargs):
|
|
117
|
+
# no longer update the slug once it's been published
|
|
118
|
+
if not self.is_published and not self.slug:
|
|
119
|
+
self.slug = slugify(self.title)
|
|
120
|
+
if not self.summary:
|
|
121
|
+
self.summary = bleach.clean(
|
|
122
|
+
markdown.markdown(self.content), strip=True, tags={}
|
|
123
|
+
)[:200]
|
|
124
|
+
if self.is_published and not self.published_at:
|
|
125
|
+
self.published_at = django.utils.timezone.now()
|
|
126
|
+
elif not self.is_published:
|
|
127
|
+
self.published_at = None
|
|
128
|
+
return super().save(*args, **kwargs)
|
|
129
|
+
|
|
130
|
+
def get_absolute_url(self):
|
|
131
|
+
return reverse(
|
|
132
|
+
"post-detail", args=[self.namespace.name, self.published_at.year, self.slug]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def __str__(self):
|
|
136
|
+
return f"{self.title}"
|
|
137
|
+
|
|
138
|
+
def __repr__(self):
|
|
139
|
+
date = self.published_at or self.created_at
|
|
140
|
+
return f"<Post {self.title} - {date.year} [{self.namespace.name}]>"
|
|
141
|
+
|
|
142
|
+
class Meta:
|
|
143
|
+
ordering = ["-published_at"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Tag(models.Model):
|
|
147
|
+
name = models.CharField(max_length=256, blank=False, null=False)
|
|
148
|
+
namespace = models.ForeignKey(
|
|
149
|
+
"Namespace", on_delete=models.PROTECT, null=False, blank=False
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def get_absolute_url(self):
|
|
153
|
+
return reverse("tag-detail", args=[self.namespace.name, self.name])
|
|
154
|
+
|
|
155
|
+
def __str__(self):
|
|
156
|
+
return f"{self.name} ({self.namespace.name})"
|
|
157
|
+
|
|
158
|
+
def __repr__(self):
|
|
159
|
+
return f"<Tag {self.name} [{self.namespace.name}]>"
|
|
160
|
+
|
|
161
|
+
class Meta:
|
|
162
|
+
unique_together = ("name", "namespace")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{% load static %}
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<link rel="stylesheet" href={% static "css/style.css" %}>
|
|
8
|
+
<title>{{ CONSTANTS.site.title }}{% block title %}{% endblock %}</title>
|
|
9
|
+
{% include "includes/header.html" %}
|
|
10
|
+
{% block head_extra %}{% endblock %}
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="wrap">
|
|
14
|
+
<nav>
|
|
15
|
+
<a href={% url 'home' %}>{{ CONSTANTS.site.title }}</a>
|
|
16
|
+
<a href={% url 'post-list' 'private' %}>Friends & Family</a>
|
|
17
|
+
</nav>
|
|
18
|
+
</header>
|
|
19
|
+
<main class="wrap">
|
|
20
|
+
{% block content %}{% endblock %}
|
|
21
|
+
</main>
|
|
22
|
+
<footer>
|
|
23
|
+
{% block footer_extra %}{% endblock %}
|
|
24
|
+
</footer>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% load markdownify %}
|
|
3
|
+
|
|
4
|
+
{% block content %}
|
|
5
|
+
|
|
6
|
+
{% include "includes/about.html" %}
|
|
7
|
+
|
|
8
|
+
<hr/>
|
|
9
|
+
|
|
10
|
+
<h2>Writing</h2>
|
|
11
|
+
<ul>
|
|
12
|
+
{% for post in posts %}
|
|
13
|
+
{% include "post-detail-include.html" %}
|
|
14
|
+
{% endfor %}
|
|
15
|
+
</ul>
|
|
16
|
+
|
|
17
|
+
<hr />
|
|
18
|
+
|
|
19
|
+
<h2>Topics</h2>
|
|
20
|
+
{% for t in tags %}
|
|
21
|
+
<a href={{ t.get_absolute_url }}>{{ t.name }}</a>{% if not forloop.last %}, {% endif %}
|
|
22
|
+
{% endfor %}
|
|
23
|
+
|
|
24
|
+
{% endblock %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<li><a href="{{ post.get_absolute_url }}" class="plain"><code class="meta">{{ post.published_at | date:"Y-m-d" }}</code> {{ post.title }}</a></li>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% load markdownify %}
|
|
3
|
+
|
|
4
|
+
{% block title %} - {{ post.title }}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block content %}
|
|
7
|
+
<article class="h-entry">
|
|
8
|
+
<hgroup>
|
|
9
|
+
<h1 class="p-name">{{ post.title }}</h1>
|
|
10
|
+
<small>
|
|
11
|
+
Written on
|
|
12
|
+
<time class="dt-published" datetime="{{ post.published_at|date:'Y-m-d H:i:s' }}">{{ post.published_at|date:'Y-m-d' }}</time>
|
|
13
|
+
by <a class="p-author h-card" href="{% url 'home' %}">{{ post.author.user.username }}</a>
|
|
14
|
+
{% if post.tags %} in {% for t in post.tags.all %}<a href={{ t.get_absolute_url }}>{{ t.name }}</a> {% endfor %}{% endif %}
|
|
15
|
+
</small>
|
|
16
|
+
</hgroup>
|
|
17
|
+
<div class="e-summary" hidden="true">
|
|
18
|
+
{{ post.summary }}
|
|
19
|
+
</div>
|
|
20
|
+
<div class="e-content">
|
|
21
|
+
{{ post.content|markdownify }}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
</article>
|
|
25
|
+
{% endblock %}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Introduction to Lorem Ipsum"
|
|
3
|
+
date: 2026-01-15T10:30:00Z
|
|
4
|
+
draft: false
|
|
5
|
+
tags: ["lorem", "ipsum", "introduction"]
|
|
6
|
+
categories: ["General"]
|
|
7
|
+
author: "Lorem Writer"
|
|
8
|
+
description: "A comprehensive introduction to the world of lorem ipsum and its applications."
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Introduction to Lorem Ipsum
|
|
12
|
+
|
|
13
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
|
14
|
+
|
|
15
|
+
## The Origins
|
|
16
|
+
|
|
17
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
|
18
|
+
|
|
19
|
+
### Classical Foundations
|
|
20
|
+
|
|
21
|
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
|
22
|
+
|
|
23
|
+
## Key Concepts
|
|
24
|
+
|
|
25
|
+
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt:
|
|
26
|
+
|
|
27
|
+
- Neque porro quisquam est
|
|
28
|
+
- Qui dolorem ipsum quia dolor sit amet
|
|
29
|
+
- Consectetur adipisci velit
|
|
30
|
+
- Sed quia non numquam eius modi tempora incidunt
|
|
31
|
+
|
|
32
|
+
### Practical Applications
|
|
33
|
+
|
|
34
|
+
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
|
|
35
|
+
|
|
36
|
+
```javascript
|
|
37
|
+
const lorem = "ipsum dolor sit amet";
|
|
38
|
+
console.log(lorem);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Conclusion
|
|
42
|
+
|
|
43
|
+
Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus.
|
|
44
|
+
|
|
45
|
+
Omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Advanced Lorem Ipsum Techniques"
|
|
3
|
+
date: 2026-01-20T14:45:00Z
|
|
4
|
+
draft: false
|
|
5
|
+
tags: ["lorem", "advanced", "techniques", "tutorial"]
|
|
6
|
+
categories: ["Technical", "Tutorials"]
|
|
7
|
+
author: "Ipsum Expert"
|
|
8
|
+
description: "Exploring advanced techniques and methodologies in lorem ipsum generation and usage."
|
|
9
|
+
featured_image: "/images/lorem-advanced.jpg"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Advanced Lorem Ipsum Techniques
|
|
13
|
+
|
|
14
|
+
Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
15
|
+
|
|
16
|
+
## Understanding the Framework
|
|
17
|
+
|
|
18
|
+
Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.
|
|
19
|
+
|
|
20
|
+
### Core Principles
|
|
21
|
+
|
|
22
|
+
1. **Primum Principium**: Lorem ipsum dolor sit amet, consectetur adipiscing elit
|
|
23
|
+
2. **Secundum Principium**: Sed do eiusmod tempor incididunt ut labore et dolore
|
|
24
|
+
3. **Tertium Principium**: Ut enim ad minim veniam, quis nostrud exercitation
|
|
25
|
+
|
|
26
|
+
## Implementation Strategies
|
|
27
|
+
|
|
28
|
+
Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
|
|
29
|
+
|
|
30
|
+
### Method One: The Classical Approach
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
def generate_lorem():
|
|
34
|
+
text = "Lorem ipsum dolor sit amet"
|
|
35
|
+
return text.upper()
|
|
36
|
+
|
|
37
|
+
# Usage
|
|
38
|
+
result = generate_lorem()
|
|
39
|
+
print(result)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.
|
|
43
|
+
|
|
44
|
+
### Method Two: Modern Variations
|
|
45
|
+
|
|
46
|
+
> "Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est."
|
|
47
|
+
|
|
48
|
+
## Performance Considerations
|
|
49
|
+
|
|
50
|
+
| Metric | Classical | Modern | Optimized |
|
|
51
|
+
|--------|-----------|--------|-----------|
|
|
52
|
+
| Speed | 100ms | 50ms | 25ms |
|
|
53
|
+
| Memory | 1MB | 512KB | 256KB |
|
|
54
|
+
| Accuracy | 95% | 97% | 99% |
|
|
55
|
+
|
|
56
|
+
Omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
|
|
57
|
+
|
|
58
|
+
## Best Practices
|
|
59
|
+
|
|
60
|
+
Itaque earum rerum hic tenetur a sapiente delectus:
|
|
61
|
+
|
|
62
|
+
- **Consistency**: Ut aut reiciendis voluptatibus maiores alias consequatur
|
|
63
|
+
- **Scalability**: Aut perferendis doloribus asperiores repellat
|
|
64
|
+
- **Maintainability**: Sed ut perspiciatis unde omnis iste natus error
|
|
65
|
+
- **Documentation**: Sit voluptatem accusantium doloremque laudantium
|
|
66
|
+
|
|
67
|
+
### Error Handling
|
|
68
|
+
|
|
69
|
+
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus. Omnis voluptas assumenda est, omnis dolor repellendus.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
#!/bin/bash
|
|
73
|
+
echo "Lorem ipsum dolor sit amet"
|
|
74
|
+
exit 0
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Future Directions
|
|
78
|
+
|
|
79
|
+
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
|
|
80
|
+
|
|
81
|
+
### Emerging Trends
|
|
82
|
+
|
|
83
|
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
|
84
|
+
|
|
85
|
+
## Conclusion
|
|
86
|
+
|
|
87
|
+
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
*This post was last updated on January 20, 2026.*
|
django_mosaic/tests.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Create your tests here.
|
django_mosaic/urls.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.urls import path, include
|
|
2
|
+
from django_magic_authorization.urls import protected_path
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.conf.urls.static import static
|
|
5
|
+
|
|
6
|
+
from mosaic.views import post_list, post_detail, home, about, tag_detail
|
|
7
|
+
from mosaic.feeds import PostFeed
|
|
8
|
+
|
|
9
|
+
mosaic_patterns = [
|
|
10
|
+
path("tag/<str:name>", tag_detail, name="tag-detail"),
|
|
11
|
+
path("posts", post_list, name="post-list"),
|
|
12
|
+
path("posts/<int:year>/<str:post_slug>", post_detail, name="post-detail"),
|
|
13
|
+
path("feed", PostFeed(), name="feed"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
urlpatterns = [
|
|
17
|
+
path("", home, name="home"),
|
|
18
|
+
path("<slug:namespace>/", include(mosaic_patterns)),
|
|
19
|
+
protected_path(
|
|
20
|
+
"private/", include(mosaic_patterns), kwargs={"namespace": "private"}
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
if settings.DEBUG:
|
|
25
|
+
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
django_mosaic/views.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from django.shortcuts import render, get_object_or_404
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from mosaic.models import Post, Tag
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _get_posts(namespace="public"):
|
|
7
|
+
return Post.objects.filter(namespace__name=namespace, is_published=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def home(request):
|
|
11
|
+
posts = _get_posts()
|
|
12
|
+
tags = Tag.objects.filter(post__in=posts).distinct()
|
|
13
|
+
|
|
14
|
+
return render(
|
|
15
|
+
request,
|
|
16
|
+
"home.html",
|
|
17
|
+
{"posts": posts, "tags": tags, "CONSTANTS": settings.CONSTANTS},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def post_list(request, namespace):
|
|
22
|
+
posts = _get_posts(namespace)
|
|
23
|
+
return render(
|
|
24
|
+
request, "post-list.html", {"posts": posts, "CONSTANTS": settings.CONSTANTS}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def post_detail(request, namespace, year, post_slug):
|
|
29
|
+
post = get_object_or_404(Post, slug=post_slug)
|
|
30
|
+
|
|
31
|
+
return render(
|
|
32
|
+
request, "post-detail.html", {"post": post, "CONSTANTS": settings.CONSTANTS}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tag_detail(request, namespace, name):
|
|
37
|
+
tag = get_object_or_404(Tag, name=name, namespace__name=namespace)
|
|
38
|
+
|
|
39
|
+
posts = _get_posts(namespace).filter(tags__name=tag)
|
|
40
|
+
|
|
41
|
+
return render(
|
|
42
|
+
request,
|
|
43
|
+
"tag-detail.html",
|
|
44
|
+
{"posts": posts, "tag": tag, "CONSTANTS": settings.CONSTANTS},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def about(request):
|
|
49
|
+
return render(request, "about.html")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-mosaic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple django blogging engine for the indieweb
|
|
5
|
+
Keywords: django,blog,indieweb
|
|
6
|
+
Author: j23n
|
|
7
|
+
Author-email: j23n <oss@j23n.com>
|
|
8
|
+
License: BSD 3-Clause License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026, j23n
|
|
11
|
+
|
|
12
|
+
Redistribution and use in source and binary forms, with or without
|
|
13
|
+
modification, are permitted provided that the following conditions are met:
|
|
14
|
+
|
|
15
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
16
|
+
list of conditions and the following disclaimer.
|
|
17
|
+
|
|
18
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
19
|
+
this list of conditions and the following disclaimer in the documentation
|
|
20
|
+
and/or other materials provided with the distribution.
|
|
21
|
+
|
|
22
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
23
|
+
contributors may be used to endorse or promote products derived from
|
|
24
|
+
this software without specific prior written permission.
|
|
25
|
+
|
|
26
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
27
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
28
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
29
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
30
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
31
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
32
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
33
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
34
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
35
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
36
|
+
|
|
37
|
+
Classifier: Development Status :: 3 - Alpha
|
|
38
|
+
Classifier: Environment :: Web Environment
|
|
39
|
+
Classifier: Framework :: Django
|
|
40
|
+
Classifier: Framework :: Django :: 5.2
|
|
41
|
+
Classifier: Intended Audience :: Developers
|
|
42
|
+
Classifier: Operating System :: OS Independent
|
|
43
|
+
Classifier: Programming Language :: Python
|
|
44
|
+
Classifier: Programming Language :: Python :: 3
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
47
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
48
|
+
Requires-Dist: bleach>=6.3.0
|
|
49
|
+
Requires-Dist: django>=5.2.11
|
|
50
|
+
Requires-Dist: django-magic-authorization>=0.1.5
|
|
51
|
+
Requires-Dist: django-markdownify>=0.9.6
|
|
52
|
+
Requires-Dist: fabric>=3.2.2
|
|
53
|
+
Requires-Dist: pillow>=12.1.0
|
|
54
|
+
Requires-Python: >=3.12
|
|
55
|
+
Project-URL: Homepage, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD
|
|
56
|
+
Project-URL: Repository, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD
|
|
57
|
+
Project-URL: Bug Tracker, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD/issues
|
|
58
|
+
Project-URL: Changelog, https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:zVdCS3rdBtnLLui7jaHTf4X9j6FD/tree/CHANGELOG.md
|
|
59
|
+
Description-Content-Type: text/markdown
|
|
60
|
+
|
|
61
|
+
# Mosaic
|
|
62
|
+
|
|
63
|
+
A simple blog system in the spirit of the <a href="https://indieweb.org">IndieWeb</a>. It's aimed to get up and running as quickly as possible with your own, easily customizable CMS
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
First, install the package using your favorite python package manager
|
|
68
|
+
|
|
69
|
+
`uv add django-mosaic`
|
|
70
|
+
|
|
71
|
+
or
|
|
72
|
+
|
|
73
|
+
`pip install django-mosaic`
|
|
74
|
+
|
|
75
|
+
Second, you need to enable the app in your Django project.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# settings.py
|
|
79
|
+
INSTALLED_APPS = [
|
|
80
|
+
...
|
|
81
|
+
"django_mosaic"
|
|
82
|
+
...
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Third, run the migrations to add the relevant schemas to your database
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
uv run python manage.py migrate
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Quickstart
|
|
93
|
+
|
|
94
|
+
Start the development server.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
uv run python manage.py runserver
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Mosaic exposes all its features through the admin. First, create a user for the admin.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
uv run python manage.py createsuperuser
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Go to <a href="http://localhost:8000/admin">http://localhost:8000/admin"</a>.
|
|
107
|
+
|
|
108
|
+
![[docs/img/01-admin.png]]
|
|
109
|
+
|
|
110
|
+
You can write a post right within the admin, in [markdown](https://daringfireball.net/projects/markdown/).
|
|
111
|
+
|
|
112
|
+
![[docs/img/02-create-post.png]]
|
|
113
|
+
|
|
114
|
+
By default, there are two `namespace`s, `public` and `private`. Everything you post in `public` will be visible to, well, everyone. Posts in `private` will be visible only to those with a secret `AccessToken`, which you can also generate in the admin.
|
|
115
|
+
|
|
116
|
+
Only `Post`s with the `is_draft` flag set to `False` will be shown on your website.
|
|
117
|
+
|
|
118
|
+
Hit the save button and go to [https://localhost:8000]
|
|
119
|
+
|
|
120
|
+
![[docs/img/03-home.png]]
|
|
121
|
+
|
|
122
|
+
Have fun!
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
django_mosaic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
django_mosaic/admin.py,sha256=lFCV0wMq7Mn5TUWzGX8e7HJFY6y4lyQwLNU-9iZkcFs,2169
|
|
3
|
+
django_mosaic/apps.py,sha256=YOJ8IE-88UocaWFDcKsyCfoTEQx1UYBKQNq24xNCu08,85
|
|
4
|
+
django_mosaic/feeds.py,sha256=m0FBPYAOwOLRwj9E719QgainaPcDlSyfKmklGCvvhx4,644
|
|
5
|
+
django_mosaic/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
django_mosaic/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
django_mosaic/management/commands/import.py,sha256=fVmwmm849xRvKH2CtNB_ntD6qkwEy5kTnyP1bSQkqbU,2800
|
|
8
|
+
django_mosaic/migrations/0001_initial_squashed_0008_alter_tag_name_alter_tag_unique_together.py,sha256=6mR9oPji8tbArE-KybAunmLMPwnSxFZ0ra3ZdfqxXHg,4086
|
|
9
|
+
django_mosaic/migrations/0009_alter_post_published_at.py,sha256=EC_8KIbL4c8SYAcXvFoPeyojXe6JPRIrgT4KxYJJteM,469
|
|
10
|
+
django_mosaic/migrations/0010_alter_post_published_at_alter_post_slug_and_more.py,sha256=2X30klrDVpyPcPYDAQYe7MHR7gyByyhGSJiNIEroVTk,761
|
|
11
|
+
django_mosaic/migrations/0011_remove_post_is_draft_post_is_published_and_more.py,sha256=S0tz3PZ4H34NOhL8Iy9jT37jg7568keMXIQz0g63jY4,1042
|
|
12
|
+
django_mosaic/migrations/0012_author_post_author.py,sha256=0ddEJIVRH_0honzg4eIkDHXrb1TAYI8T8PTmAmv5br0,1096
|
|
13
|
+
django_mosaic/migrations/0013_add_author_to_post.py,sha256=QfKBB5YXDsT5SGYY-fcqvooqd8txlNzAhVwhBeeh1aU,675
|
|
14
|
+
django_mosaic/migrations/0014_contentimage.py,sha256=GPevq1rikS8U1XwEnTouSM_ta7j_KKFORoPH3sphIKg,1255
|
|
15
|
+
django_mosaic/migrations/0015_contentimage_post.py,sha256=l-N8FHJGbSxO5QDuqQpqRldhMeKbnchvYmFWEeU3-lw,540
|
|
16
|
+
django_mosaic/migrations/0016_alter_contentimage_image_alter_contentimage_thumb.py,sha256=PjsNi1Q4pO-6JHTaq4dEofoQHKNq4dLUVe1LMVSjyT8,646
|
|
17
|
+
django_mosaic/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
django_mosaic/models.py,sha256=wbBlgpLy5Qycn3ObTaIJJCWgu3HFUjO5HzRC4L8NR0U,5323
|
|
19
|
+
django_mosaic/templates/base.html,sha256=xZw1wdaLz3Ly_desnqux-iGiu2iiuXR0fos4u4DZ1sA,696
|
|
20
|
+
django_mosaic/templates/home.html,sha256=7OKq65DyP2iaQW3peVASJB_gjM9Xenxsnkx5zdzxqq4,383
|
|
21
|
+
django_mosaic/templates/post-detail-include.html,sha256=isuSnEbl_4_fnJsKFxRN2IN11FY7cAd7A_cvof80iS0,147
|
|
22
|
+
django_mosaic/templates/post-detail.html,sha256=mWNQ8nQMIVVonykW7Y2YsiB_NtwWjpK8lfMgjOmdtCk,728
|
|
23
|
+
django_mosaic/templates/post-list.html,sha256=1Hg4krfs3SIaiYZCaUGgOy9GkEr3tG4fvFdUUmLuvn8,243
|
|
24
|
+
django_mosaic/templates/tag-detail.html,sha256=jU88SR4MNLwsD6pvQMpfQv4OlOCDdA-8U2s0tIrrrhA,191
|
|
25
|
+
django_mosaic/tests/post-1-introduction-to-lorem.md,sha256=JX2NcpbQvxZcnb6uGWEHnsPLhV-jyNZYP4NWIk6-vpU,2138
|
|
26
|
+
django_mosaic/tests/post-2-advanced-techniques.md,sha256=pFe5Yv_x1jfH1fdyewtmqmMRWUfCcjBLmKexMeCSyIM,3944
|
|
27
|
+
django_mosaic/tests.py,sha256=qWDvA9ZhVCQ1rPbkoFify7o_fDirXMUdYMxF12q3WIM,26
|
|
28
|
+
django_mosaic/urls.py,sha256=rv4jssWfX_qrzIP5hVw_Bx06IkUmDH_bqhDuo5x45KU,848
|
|
29
|
+
django_mosaic/views.py,sha256=U32A3UpLXv-Yhs65Z9jIP-OxCQIZ-DLzviAZrmb5hss,1255
|
|
30
|
+
django_mosaic-0.1.0.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
31
|
+
django_mosaic-0.1.0.dist-info/METADATA,sha256=UT9nOgs89T-FQ6m8mQwQkMM5TgdREBjMgcYegHL62Q0,4675
|
|
32
|
+
django_mosaic-0.1.0.dist-info/RECORD,,
|