import base64
import binascii
import os
import uuid
import re
from django.db.models import Q
import bleach
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils import timezone
from main import util, validators
def _generate_key():
"""Return 32-char random string."""
return binascii.b2a_hex(os.urandom(16)).decode("utf-8")
# custom queryset and manager for Post model to handle tag operations
class PostQuerySet(models.QuerySet):
def with_tag(self, tag):
return self.filter(
Q(tags__regex=rf'(^|,){re.escape(tag)}(,|$)')
)
def all_unique_tags(self):
tags = set()
for post in self.exclude(tags__isnull=True).exclude(tags=""):
for tag in post.tag_list:
tags.add(tag)
return sorted(tags)
class PostManager(models.Manager):
def get_queryset(self):
return PostQuerySet(self.model, using=self._db)
def with_tag(self, tag):
return self.get_queryset().with_tag(tag)
def all_unique_tags(self):
return self.get_queryset().all_unique_tags()
class User(AbstractUser):
username = models.CharField(
max_length=150,
unique=True,
help_text="This is your subdomain. Lowercase alphanumeric.",
validators=[
validators.AlphanumericHyphenValidator(),
validators.HyphenOnlyValidator(),
],
error_messages={"unique": "A user with that username already exists."},
)
email = models.EmailField(
blank=True,
null=True,
help_text="Optional, but also the only way to recover password if forgotten.",
)
api_key = models.CharField(max_length=32, default=_generate_key, unique=True)
about = models.TextField(blank=True, null=True)
blog_title = models.CharField(max_length=500, blank=True, null=True)
posts_page_title = models.CharField(max_length=500, blank=True, null=True)
blog_byline = models.TextField(
blank=True,
null=True,
help_text="Supports markdown",
)
blog_index_content = models.TextField(
blank=True,
null=True,
help_text="Supports markdown",
)
subscribe_note = models.CharField(
max_length=350,
blank=True,
null=True,
default="Subscribe via [RSS](/rss/) / [Atom](/atom/) / [via Email](/newsletter/).",
help_text="Default: Subscribe via [RSS](/rss/) / [Atom](/atom/) / [via Email](/newsletter/).",
)
footer_note = models.TextField(
blank=True,
null=True,
default="published with [BōcPress](https://bocpress.co.uk/).",
help_text="Supports markdown",
)
theme_zialucia = models.BooleanField(
default=False,
verbose_name="Theme Zia Lucia",
help_text="Enable/disable Zia Lucia theme with larger default font size.",
)
theme_sansserif = models.BooleanField(
default=False,
verbose_name="Theme Sans-serif",
help_text="Use sans-serif font in blog content.",
)
redirect_domain = models.CharField(
max_length=150,
blank=True,
null=True,
help_text="Retiring your BōcPress blog? We can redirect to your new domain.",
validators=[validators.validate_domain_name],
)
custom_domain = models.CharField(
max_length=150,
blank=True,
null=True,
help_text="To setup: Add an A record in your domain's DNS with IP 78.47.67.77",
validators=[validators.validate_domain_name],
)
comments_on = models.BooleanField(
default=False,
help_text="Enable/disable comments for your blog.",
verbose_name="Comments",
)
notifications_on = models.BooleanField(
default=True,
help_text="Allow/disallow people subscribing for email newsletter for new posts.",
verbose_name="Newsletter",
)
mail_export_on = models.BooleanField(
default=False,
help_text="Enable/disable auto emailing of account exports every month.",
verbose_name="Mail export",
)
post_backups_on = models.BooleanField(
default=False,
help_text="Enable/disable automatic post backups.",
verbose_name="Post Backups On",
)
show_posts_on_homepage = models.BooleanField(
default=True,
help_text="Show/hide posts on the homepage.",
verbose_name="Show Posts On Homepage",
)
show_posts_in_nav = models.BooleanField(
default=False,
help_text="Show/hide posts in the navigation bar.",
verbose_name="Show Posts In Nav",
)
show_tags_in_post_list = models.BooleanField(
default=True,
help_text="Show/hide tags in the post list.",
verbose_name="Show Tags In Post List",
)
noindex_on = models.BooleanField(
default=False,
help_text="Add a noindex meta tag so your blog is not indexed by search engines.",
verbose_name="noindex: Prevent Search Engine Indexing",
)
reading_time_on = models.BooleanField(
default=False,
help_text="Add an estimated reading time - in minutes - to the top of the post body.",
verbose_name="Show Reading Time on Posts",
)
robots_txt = models.TextField(
blank=True,
help_text="Custom robots.txt content for your blog. Leave blank for default.",
verbose_name="robots.txt",
null=True,
default="User-agent: *\nDisallow:\nAllow: /",
)
export_unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True)
number_of_posts_feed = models.IntegerField(
blank=False,
help_text="Number of posts to show in RSS/Atom feed. Default is 10.",
verbose_name="Number of Posts in Feed",
null=False,
default=10,
)
# webring related
webring_name = models.CharField(max_length=200, blank=True, null=True)
webring_url = models.URLField(
blank=True,
null=True,
verbose_name="Webring info URL",
help_text="Informational URL.",
)
webring_prev_url = models.URLField(
blank=True,
null=True,
verbose_name="Webring previous URL",
help_text="URL for your webring's previous website.",
)
webring_next_url = models.URLField(
blank=True,
null=True,
verbose_name="Webring next URL",
help_text="URL for your webring's next website.",
)
markdown_auto_format_on = models.BooleanField(
default=False,
help_text="Enable/disable automatic markdown formatting.",
verbose_name="Auto Markdown formatting",
)
# billing
stripe_customer_id = models.CharField(max_length=100, blank=True, null=True)
stripe_subscription_id = models.CharField(max_length=100, blank=True, null=True)
monero_address = models.CharField(max_length=95, blank=True, null=True)
is_premium = models.BooleanField(default=False)
is_grandfathered = models.BooleanField(default=False)
# moderation
is_approved = models.BooleanField(default=False)
class Meta:
ordering = ["-id"]
@property
def blog_absolute_url(self):
protocol = f"{util.get_protocol()}"
return f"{protocol}//{self.username}.{settings.CANONICAL_HOST}"
@property
def blog_url(self):
url = f"{util.get_protocol()}"
if self.custom_domain:
return url + f"//{self.custom_domain}"
else:
return url + f"//{self.username}.{settings.CANONICAL_HOST}"
@property
def blog_byline_as_text(self):
linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None])
html_text = util.md_to_html(self.blog_byline, strip_tags=True)
return linker.linkify(html_text)
@property
def blog_byline_as_html(self):
return util.md_to_html(self.blog_byline)
@property
def blog_index_content_as_text(self):
linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None])
html_text = util.md_to_html(self.blog_index_content, strip_tags=True)
return linker.linkify(html_text)
@property
def blog_index_content_as_html(self):
return util.md_to_html(self.blog_index_content)
@property
def about_as_html(self):
return util.md_to_html(self.about, strip_tags=True)
@property
def subscribe_note_as_html(self):
return util.md_to_html(self.subscribe_note)
@property
def footer_note_as_html(self):
return util.md_to_html(self.footer_note)
@property
def post_count(self):
return Post.objects.filter(owner=self).count()
@property
def class_status(self):
if self.is_premium or self.is_grandfathered:
return "💠"
return "∅"
def get_export_unsubscribe_url(self):
domain = self.custom_domain or f"{self.username}.{settings.CANONICAL_HOST}"
path = reverse("export_unsubscribe_key", args={self.export_unsubscribe_key})
return f"//{domain}{path}"
def reset_api_key(self):
self.api_key = _generate_key()
self.save()
def __str__(self):
return self.username
class Post(models.Model):
title = models.CharField(max_length=300)
slug = models.CharField(max_length=300)
body = models.TextField(blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateField(
default=timezone.now,
blank=True,
null=True,
help_text="Leave blank to keep as draft/unpublished. Use a future date for auto-posting.",
)
broadcasted_at = models.DateTimeField(blank=True, null=True, default=None)
tags = models.CharField(max_length=300, blank=True, null=True, default=None, help_text="Enter comma-separated tags (e.g., django, python, blog).")
class Meta:
ordering = ["-published_at", "-created_at"]
unique_together = [["slug", "owner"]]
objects = PostManager()
@property
def body_as_html(self):
return util.md_to_html(self.body)
@property
def body_as_text(self):
as_html = util.md_to_html(self.body)
return bleach.clean(as_html, strip=True, tags=[])
@property
def tag_list(self):
if self.tags:
return [t.strip() for t in self.tags.split(",") if t.strip()]
return []
@property
def is_draft(self):
return not self.published_at
@property
def is_published(self):
# draft case
if not self.published_at:
return False
# future publishing date case
if self.published_at > timezone.now().date(): # noqa: SIM103
return False
return True
def get_absolute_url(self):
path = reverse("post_detail", kwargs={"slug": self.slug})
return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}"
def get_proper_url(self):
"""Returns custom domain URL if custom_domain exists, else subdomain URL."""
if self.owner.custom_domain:
path = reverse("post_detail", kwargs={"slug": self.slug})
return f"//{self.owner.custom_domain}{path}"
else:
return self.get_absolute_url()
def save(self, *args, **kwargs):
if self.tags:
cleaned_tags = [t.strip() for t in self.tags.split(",") if t.strip()]
self.tags = ",".join(cleaned_tags)
super().save(*args, **kwargs)
def __str__(self):
return self.title
class Image(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=300) # original filename
slug = models.CharField(max_length=300, unique=True)
data = models.BinaryField()
extension = models.CharField(max_length=10)
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-uploaded_at"]
@property
def filename(self):
return self.slug + "." + self.extension
@property
def data_as_base64(self):
return base64.b64encode(self.data).decode("utf-8")
@property
def data_size(self):
"""Get image size in MB."""
return round(len(self.data) / (1024 * 1024), 2)
@property
def raw_url_absolute(self):
path = reverse(
"image_raw", kwargs={"slug": self.slug, "extension": self.extension}
)
return f"//{settings.CANONICAL_HOST}{path}"
def get_absolute_url(self):
path = reverse("image_detail", kwargs={"slug": self.slug})
return f"//{settings.CANONICAL_HOST}{path}"
def __str__(self):
return self.name
class Page(models.Model):
title = models.CharField(max_length=300)
slug = models.CharField(
max_length=300,
validators=[validators.AlphanumericHyphenValidator()],
help_text="Lowercase letters, numbers, and - (hyphen) allowed.",
)
body = models.TextField(blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_hidden = models.BooleanField(
default=False,
help_text="If checked, page link will not appear on the blog header.",
)
class Meta:
ordering = ["slug"]
unique_together = [["slug", "owner"]]
@property
def body_as_html(self):
return util.md_to_html(self.body)
def get_absolute_url(self):
path = reverse("page_detail", kwargs={"slug": self.slug})
return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}"
def __str__(self):
return self.title
class AnalyticPage(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
path = models.CharField(max_length=300)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.created_at.strftime("%c") + ": " + self.user.username
class AnalyticPost(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.created_at.strftime("%c") + ": " + self.post.title
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
body = models.TextField()
name = models.CharField(max_length=150, default="Anonymous", null=True, blank=True)
email = models.EmailField(null=True, blank=True)
is_approved = models.BooleanField(default=False)
is_author = models.BooleanField(
default=False, help_text="True if logged in author has posted comment."
)
class Meta:
ordering = ["created_at"]
@property
def body_as_html(self):
return util.md_to_html(self.body)
def get_absolute_url(self):
path = reverse("post_detail", kwargs={"slug": self.post.slug})
return f"//{self.post.owner.username}.{settings.CANONICAL_HOST}{path}#comment-{self.id}"
def __str__(self):
return self.created_at.strftime("%c") + ": " + self.post.title
class Notification(models.Model):
blog_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
email = models.EmailField()
unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["email"]
unique_together = [["email", "blog_user"]]
def get_unsubscribe_url(self):
domain = (
self.blog_user.custom_domain
or f"{self.blog_user.username}.{settings.CANONICAL_HOST}"
)
path = reverse("notification_unsubscribe_key", args={self.unsubscribe_key})
return f"//{domain}{path}"
def __str__(self):
return self.email + " – " + str(self.unsubscribe_key)
class NotificationRecord(models.Model):
"""
NotificationRecord model is to keep track of all notifications
for the newsletter feature.
"""
notification = models.ForeignKey(Notification, on_delete=models.SET_NULL, null=True)
post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True)
sent_at = models.DateTimeField(default=timezone.now, null=True)
class Meta:
ordering = ["-sent_at"]
unique_together = [["post", "notification"]]
def __str__(self):
if not self.sent_at:
return str(self.id)
if self.notification:
return self.sent_at.strftime("%c") + " – " + self.notification.email
else:
return self.sent_at.strftime("%c") + " – NULL"
class ExportRecord(models.Model):
"""ExportRecord model is to keep track of each export email."""
name = models.CharField(max_length=150)
user = models.ForeignKey(User, on_delete=models.CASCADE)
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-sent_at"]
def __str__(self):
return self.name
class Snapshot(models.Model):
"""Snapshot model is used to keep track of all versions of Posts."""
title = models.CharField(max_length=300)
body = models.TextField(blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
class Onboard(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
code = models.UUIDField(default=uuid.uuid4, unique=True)
problems = models.CharField(max_length=300, null=True, blank=True)
quality = models.CharField(max_length=300, null=True, blank=True)
seo = models.CharField(max_length=300, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Prb: {self.problems} / Qlt: {self.quality}"
class ReallySimpleLicensing(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="reallysimplelicensing")
license = models.TextField(blank=True, null=True, help_text="License text in XML format defined by the Really Simple Licensing standard. Will be available at /license.xml.")
show_http = models.BooleanField(default=True, verbose_name="Show HTTP License", help_text="Show/hide license in HTTP header.")
show_rss = models.BooleanField(default=True, verbose_name="Show RSS License", help_text="Show/hide license in RSS feed.")
show_robotstxt = models.BooleanField(default=True, verbose_name="Show robots.txt License", help_text="Show/hide license in robots.txt.")
show_webpage = models.BooleanField(default=True, verbose_name="Show Webpage License", help_text="Show/hide license on webpage header.")