Commit 2940142e by benjaoming

More work on models. Add Attachment model, and let attachments pass through a revision system.

parent 322b5a6a
django-wiki django-wiki
=========== ===========
*2012-07-23* *2012-07-27*
This is where it all begins. In 4 weeks we should have a wiki system appealing to any kind of Django developer out there. Here is the manifest (so far): This is where it all begins. In less than 4 weeks we should have a wiki system appealing to any kind of Django developer out there. Here is the manifest (so far):
* **Be pluggable and light-weight.** Don't integrate optional features in the core. * **Be pluggable and light-weight.** Don't integrate optional features in the core.
* **Be open.** Make an extension API that allows the ecology of the wiki to grow. After all, Wikipedia consists of some [680 extensions](http://svn.wikimedia.org/viewvc/mediawiki/trunk/extensions/) written for MediaWiki. * **Be open.** Make an extension API that allows the ecology of the wiki to grow. After all, Wikipedia consists of some [680 extensions](http://svn.wikimedia.org/viewvc/mediawiki/trunk/extensions/) written for MediaWiki.
* **Be smart.** [This is](https://upload.wikimedia.org/wikipedia/commons/8/88/MediaWiki_database_schema_1-19_%28r102798%29.png) the map of tables in MediaWiki - we'll understand the choices of other wiki projects and make our own. After-all, this is a Django project. * **Be smart.** [This is](https://upload.wikimedia.org/wikipedia/commons/8/88/MediaWiki_database_schema_1-19_%28r102798%29.png) the map of tables in MediaWiki - we'll understand the choices of other wiki projects and make our own. After-all, this is a Django project.
* **Be simple.** The source code should explain itself. * **Be simple.** The source code should explain itself.
* **Be structured.** Markdown is a simple syntax for readability. Features should be implemented either through easy coding patterns in the content field, but rather stored in a structured way (in the database) and managed through a friendly interface. This gives control back to the website developer, and makes knowledge more usable. Just ask: Why has Wikipedia never changed? Answer: Because it's knowledge is stored in a complicated way, thus it becomes very static.
Installation Installation
------------ ------------
......
Management script:
Cleanup deleted Image's image files
Cleanup revisions
...@@ -3,19 +3,48 @@ from django.contrib.contenttypes.generic import GenericTabularInline ...@@ -3,19 +3,48 @@ from django.contrib.contenttypes.generic import GenericTabularInline
from mptt.admin import MPTTModelAdmin from mptt.admin import MPTTModelAdmin
import models import models
from django import forms
from django.forms.widgets import HiddenInput
class ArticleObjectAdmin(GenericTabularInline): class ArticleObjectAdmin(GenericTabularInline):
model = models.ArticleForObject model = models.ArticleForObject
extra = 1 extra = 1
max_num = 1 max_num = 1
class ArticleForm(forms.ModelForm):
class Meta:
model = models.Article
def __init__(self, *args, **kwargs):
super(ArticleForm, self).__init__(*args, **kwargs)
if self.instance.pk:
revisions = models.ArticleRevision.objects.filter(article=self.instance)
self.fields['current_revision'].queryset = revisions
else:
self.fields['current_revision'].queryset = models.ArticleRevision.objects.get_empty_query_set()
self.fields['current_revision'].widget = HiddenInput()
class ArticleRevisionInline(admin.TabularInline):
model = models.ArticleRevision
fk_name = 'article'
extra = 1
fields = ('content', 'title', 'user', 'user_message', 'deleted', 'locked', 'redirect')
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(admin.ModelAdmin):
pass inlines = [ArticleRevisionInline]
form = ArticleForm
class URLPathAdmin(MPTTModelAdmin): class URLPathAdmin(MPTTModelAdmin):
inlines = [ArticleObjectAdmin] inlines = [ArticleObjectAdmin]
list_filter = ('site',) list_filter = ('site', 'articles__article__current_revision__deleted',
list_display = ('slug', 'article') 'articles__article__current_revision__created',
'articles__article__modified')
list_display = ('__unicode__', 'article', 'created')
class ArticleRevisionAdmin(admin.ModelAdmin):
pass
admin.site.register(models.URLPath, URLPathAdmin) admin.site.register(models.URLPath, URLPathAdmin)
admin.site.register(models.Article, ArticleAdmin) admin.site.register(models.Article, ArticleAdmin)
admin.site.register(models.ArticleRevision, ArticleRevisionAdmin)
\ No newline at end of file
...@@ -5,3 +5,52 @@ from django.conf import settings as django_settings ...@@ -5,3 +5,52 @@ from django.conf import settings as django_settings
URL_CASE_SENSITIVE = getattr(django_settings, "WIKI_URL_CASE_SENSITIVE", False) URL_CASE_SENSITIVE = getattr(django_settings, "WIKI_URL_CASE_SENSITIVE", False)
APP_LABEL = 'wiki' APP_LABEL = 'wiki'
# This slug is used in URLPath if an article has been deleted. The children of the
# URLPath of that article are moved to lost and found. They keep their permissions
# and all their content.
LOST_AND_FOUND_SLUG = getattr(django_settings, "WIKI_LOST_AND_FOUND_SLUG", 'lost-and-found')
# Where to store article attachments, relative to MEDIA_ROOT
UPLOAD_PATH = getattr(django_settings, "WIKI_UPLOAD_PATH", 'wiki/uploads/%aid/')
# Should the upload path be obscurified? If so, a random hash will be added to the path
# such that someone can not guess the location of files (if you have
# restricted permissions and the files are still located within the web server's
UPLOAD_PATH_OBSCURIFY = getattr(django_settings, "WIKI_UPLOAD_PATH_OBSCURIFY", True)
# Allowed non-image extensions. Empty to disallow completely.
# No files are saved without appending ".upload" to the file to ensure that
# your web server never actually executes some script.
# Case insensitive.
FILE_EXTENTIONS = getattr(django_settings, "WIKI_FILE_EXTENTIONS", ['pdf', 'doc', 'odt', 'docx', 'txt'])
# Where to store images
IMAGE_PATH = getattr(django_settings, "WIKI_IMAGE_PATH", 'wiki/images/%aid/')
####################
# PLANNED SETTINGS #
####################
# Maximum revisions to keep for an article, 0=unlimited
MAX_REVISIONS = getattr(django_settings, "WIKI_MAX_REVISIONS", 100)
# Maximum age of revisions in days, 0=unlimited
MAX_REVISION_AGE = getattr(django_settings, "MAX_REVISION_AGE", 365)
LOG_IPS_ANONYMOUS = getattr(django_settings, "WIKI_LOG_IPS_ANONYMOUS", True)
LOG_IPS_USERS = getattr(django_settings, "WIKI_LOG_IPS_USERS", False)
# Maximum allowed revisions per minute for any given user or IP
REVISIONS_PER_MINUTE = getattr(django_settings, "WIKI_REVISIONS_PER_MINUTE", 3)
# Allow others to upload
UPLOAD_OTHERS = getattr(django_settings, "WIKI_UPLOAD_OTHERS", True)
# Treat anonymous (non logged in) users as the "other" user group
ANONYMOUS = getattr(django_settings, "WIKI_ANONYMOUS", True)
# Globally enable write access for anonymous users, if true anonymous users will be treated
# as the others_write boolean field on models.Article.
ANONYMOUS_WRITE = getattr(django_settings, "WIKI_ANONYMOUS_WRITE", False)
...@@ -6,3 +6,7 @@ class NoRootURL(Exception): ...@@ -6,3 +6,7 @@ class NoRootURL(Exception):
# If there is more than one... # If there is more than one...
class MultipleRootURLs(Exception): class MultipleRootURLs(Exception):
pass pass
class IllegalFileExtension(Exception):
"""File extension on upload is not allowed"""
pass
...@@ -4,8 +4,9 @@ from django.conf import settings as django_settings ...@@ -4,8 +4,9 @@ from django.conf import settings as django_settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
import warnings import warnings
from article import Article, ArticleRevision, ArticleForObject # TODO: Don't use wildcards
from urlpath import URLPath from article import *
from urlpath import *
###################### ######################
# Configuration stuff # Configuration stuff
......
...@@ -6,25 +6,36 @@ from django.contrib.contenttypes import generic ...@@ -6,25 +6,36 @@ from django.contrib.contenttypes import generic
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from wiki.conf import settings from wiki.conf import settings
from wiki.core import exceptions
class Article(models.Model): class Article(models.Model):
title = models.CharField(max_length=512, verbose_name=_(u'title'), title = models.CharField(max_length=512, verbose_name=_(u'title'),
null=False, blank=False) null=False, blank=False, help_text=_(u'Initial title of the article. '
current_revision = models.ForeignKey('ArticleRevision', 'May be overridden with revision titles.'))
current_revision = models.OneToOneField('ArticleRevision',
verbose_name=_(u'current revision'), verbose_name=_(u'current revision'),
blank=True, null=True, related_name='current_set') blank=True, null=True, related_name='current_set',
help_text=_(u'The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field.'),
)
modified = models.DateTimeField(auto_now=True, verbose_name=_(u'modified'),
help_text=_(u'Article properties last modified'))
owner = models.ForeignKey(User, verbose_name=_('owner'), owner = models.ForeignKey(User, verbose_name=_('owner'),
blank=True, null=True) blank=True, null=True,
help_text=_(u'The owner of the article, usually the creator. The owner always has both read and write access.'),)
group = models.ForeignKey(Group, verbose_name=_('group'), group = models.ForeignKey(Group, verbose_name=_('group'),
blank=True, null=True) blank=True, null=True,
help_text=_(u'Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system.'),)
group_read = models.BooleanField(default=True, verbose_name=_(u'group read access'))
group_write = models.BooleanField(default=True, verbose_name=_(u'group write access'))
other_read = models.BooleanField(default=True, verbose_name=_(u'others read access'))
other_write = models.BooleanField(default=True, verbose_name=_(u'others write access'))
group_read = models.BooleanField(default=True) attachments = models.ManyToManyField('Attachment', blank=True, verbose_name=_(u'attachments'))
group_write = models.BooleanField(default=True)
other_read = models.BooleanField(default=True)
other_write = models.BooleanField(default=True)
def can_read(self, user=None, group=None): def can_read(self, user=None, group=None):
if self.other_read: if self.other_read:
...@@ -113,8 +124,13 @@ class Article(models.Model): ...@@ -113,8 +124,13 @@ class Article(models.Model):
class Meta: class Meta:
app_label = settings.APP_LABEL app_label = settings.APP_LABEL
def render_contents(self):
if not self.current_revision:
return ""
class ArticleForObject(models.Model): class ArticleForObject(models.Model):
article = models.ForeignKey('Article') article = models.ForeignKey('Article', on_delete=models.CASCADE)
# Same as django.contrib.comments # Same as django.contrib.comments
content_type = models.ForeignKey(ContentType, content_type = models.ForeignKey(ContentType,
verbose_name=_('content type'), verbose_name=_('content type'),
...@@ -131,16 +147,51 @@ class ArticleForObject(models.Model): ...@@ -131,16 +147,51 @@ class ArticleForObject(models.Model):
# Do not allow several objects # Do not allow several objects
unique_together = ('content_type', 'object_id') unique_together = ('content_type', 'object_id')
class ArticleRevision(models.Model): class BaseRevision(models.Model):
revision_number = models.IntegerField(editable=False, verbose_name=_(u'revision number'))
article = models.ForeignKey('Article', on_delete=models.CASCADE,) user_message = models.CharField(blank=True, max_length=2056)
revision_number = models.IntegerField() automatic_log = models.TextField(blank=True, editable=False,)
ip_address = models.IPAddressField(_('IP address'), blank=True, null=True, editable=False)
user = models.ForeignKey(User, verbose_name=_('user'),
blank=True, null=True)
modified = models.DateTimeField(auto_now=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
app_label = settings.APP_LABEL
get_latest_by = ('revision_number',)
def save(self, *args, **kwargs):
if not self.revision_number:
try:
previous_revision = self.article.articlerevision_set.latest()
self.revision_number = previous_revision.revision_number + 1
except ArticleRevision.DoesNotExist:
self.revision_number = 1
super(BaseRevision, self).save(*args, **kwargs)
class ArticleRevision(BaseRevision):
article = models.ForeignKey('Article', on_delete=models.CASCADE,
verbose_name=_(u'article'))
# This is where the content goes, with whatever markup language is used # This is where the content goes, with whatever markup language is used
content = models.TextField(blank=True) content = models.TextField(blank=True, verbose_name=_(u'article contents'))
# This title is automatically set from either the article's title or
# the last used revision...
title = models.CharField(max_length=512, verbose_name=_(u'article title'),
null=False, blank=False, help_text=_(u'Each revision contains a title field that must be filled out, even if the title has not changed'))
# Simple properties # Simple properties
deleted = models.BooleanField(verbose_name=_(u'Article has been deleted')) deleted = models.BooleanField(verbose_name=_(u'article deleted'))
locked = models.BooleanField(verbose_name=_(u'article locked'))
# Allow a revision to redirect to another *article*. This # Allow a revision to redirect to another *article*. This
# way, we can redirects and still maintain old content. # way, we can redirects and still maintain old content.
...@@ -149,16 +200,86 @@ class ArticleRevision(models.Model): ...@@ -149,16 +200,86 @@ class ArticleRevision(models.Model):
help_text=_(u'If set, the article will redirect to the contents of another article.'), help_text=_(u'If set, the article will redirect to the contents of another article.'),
related_name='redirect_set') related_name='redirect_set')
# User details def __unicode__(self):
ip_address = models.IPAddressField(_('IP address'), blank=True, null=True) return "%s (%d)" % (self.article.title, self.revision_number)
user = models.ForeignKey(User, verbose_name=_('user'),
blank=True, null=True)
# Various stuff def inherit_predecessor(self, revision):
created = models.DateTimeField(auto_now_add=True) """
modified = models.DateTimeField(auto_now=True) Inherit certain properties from predecessor because it's very
convenient. Remember to always call this method before setting properties :)"""
self.title = revision.title
self.deleted = revision.deleted
self.locked = revision.locked
self.redirect = revision.redirect
def save(self, *args, **kwargs):
super(ArticleRevision, self).save(*args, **kwargs)
if not self.article.current_revision:
# If I'm saved from Django admin, then article.current_revision is me!
self.article.current_revision = self
self.article.save()
if not self.title:
self.title = self.article.title
def upload_path(instance, filename):
from os import path
try:
extension = filename.split(".")[-1]
except IndexError:
raise exceptions.IllegalFileExtension()
if not extension.lower() in map(lambda x: x.lower(), settings.FILE_EXTENTIONS):
raise exceptions.IllegalFileExtension()
upload_path = settings.UPLOAD_PATH
upload_path = upload_path.replace('%aid', str(instance.original_article.id))
if settings.UPLOAD_PATH_OBSCURIFY:
import random, hashlib
m=hashlib.md5(str(random.randint(0,100000000000000)))
upload_path = path.join(upload_path, m.hexdigest())
return path.join(upload_path, filename + '.upload')
class Attachment(models.Model):
# The article on which the file was originally uploaded.
# Used to apply permissions.
original_article = models.ForeignKey('Article', on_delete=models.SET_NULL,
verbose_name=_(u'original article'), null=True, blank=True,
related_name='original_attachment_set')
current_revision = models.OneToOneField('AttachmentRevision',
verbose_name=_(u'current revision'),
blank=True, null=True, related_name='current_set',
help_text=_(u'The revision of this attachment currently in use (on all articles using the attachment)'),
)
class Meta: class Meta:
app_label = settings.APP_LABEL app_label = settings.APP_LABEL
get_latest_by = ('id',)
class AttachmentRevision(BaseRevision):
attachment = models.ForeignKey('Attachment')
file = models.FileField(upload_to=upload_path, #@ReservedAssignment
verbose_name=_(u'file'))
original_filename = models.CharField(max_length=256, verbose_name=_(u'original filename'))
overwritten = models.BooleanField(default=False)
class Meta:
app_label = settings.APP_LABEL
class Image(models.Model):
article = models.ForeignKey('Article', on_delete=models.CASCADE,
verbose_name=_(u'article'))
image = models.ImageField(upload_to=settings.IMAGE_PATH)
caption = models.CharField(max_length=2056)
def render_caption(self):
"""Returns a rendered version of the caption. Should only use a
subset of the rendering machine."""
pass
\ No newline at end of file
...@@ -12,14 +12,16 @@ from article import Article ...@@ -12,14 +12,16 @@ from article import Article
from wiki.models.article import ArticleRevision, ArticleForObject from wiki.models.article import ArticleRevision, ArticleForObject
from django.contrib.contenttypes import generic from django.contrib.contenttypes import generic
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.signals import pre_delete
class URLPath(MPTTModel): class URLPath(MPTTModel):
""" """
Strategy: Very few fields go here, as most has to be managed through an Strategy: Very few fields go here, as most has to be managed through an
article's revision. As a side-effect, the URL resolution remains slim and swift. article's revision. As a side-effect, the URL resolution remains slim and swift.
""" """
# Tells django-wiki that permissions from a URLPath object's article # Tells django-wiki that permissions from a this object's article
# should be inherited to children's articles # should be inherited to children's articles. In this case, it's a static
# property.. but you can also use a BooleanField.
INHERIT_PERMISSIONS = True INHERIT_PERMISSIONS = True
articles = generic.GenericRelation(ArticleForObject) articles = generic.GenericRelation(ArticleForObject)
...@@ -27,19 +29,35 @@ class URLPath(MPTTModel): ...@@ -27,19 +29,35 @@ class URLPath(MPTTModel):
site = models.ForeignKey(Site) site = models.ForeignKey(Site)
parent = TreeForeignKey('self', null=True, blank=True, related_name='children') parent = TreeForeignKey('self', null=True, blank=True, related_name='children')
def get_path(self): @property
"/".join([obj.slug for obj in self.get_ancestors(include_self=True)]) def path(self):
return "/".join([obj.slug for obj in self.get_ancestors(include_self=True)])
@classmethod
def root(cls):
site = Site.objects.get_current()
root_nodes = cls.objects.root_nodes().filter(site=site)
no_paths = root_nodes.count()
if no_paths == 0:
raise NoRootURL
if no_paths > 1:
raise MultipleRootURLs
return root_nodes[0]
class MPTTMeta: class MPTTMeta:
pass pass
def __unicode__(self): def __unicode__(self):
path = self.get_path() path = self.path
return path if path else ugettext(u"(root)") return path if path else ugettext(u"(root)")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super(URLPath, self).save(*args, **kwargs) super(URLPath, self).save(*args, **kwargs)
def delete(self, *args, **kwargs):
assert not self.parent and self.get_children(), "You cannot delete a root article with children."
super(URLPath, self).delete(*args, **kwargs)
class Meta: class Meta:
verbose_name = _(u'URL path') verbose_name = _(u'URL path')
verbose_name_plural = _(u'URL paths') verbose_name_plural = _(u'URL paths')
...@@ -52,7 +70,7 @@ class URLPath(MPTTModel): ...@@ -52,7 +70,7 @@ class URLPath(MPTTModel):
if not self.slug and self.parent: if not self.slug and self.parent:
raise ValidationError(_(u'A non-root note must always have a slug.')) raise ValidationError(_(u'A non-root note must always have a slug.'))
if not self.parent: if not self.parent:
if URLPath.objects.root_nodes().filter(site=self.site): if URLPath.objects.root_nodes().filter(site=self.site).exclude(id=self.id):
raise ValidationError(_(u'There is already a root node on %s') % self.site) raise ValidationError(_(u'There is already a root node on %s') % self.site)
super(URLPath, self).clean(*args, **kwargs) super(URLPath, self).clean(*args, **kwargs)
...@@ -62,22 +80,15 @@ class URLPath(MPTTModel): ...@@ -62,22 +80,15 @@ class URLPath(MPTTModel):
Strategy: Don't handle all kinds of weird cases. Be strict. Strategy: Don't handle all kinds of weird cases. Be strict.
Accepts paths both starting with and without '/' Accepts paths both starting with and without '/'
""" """
site = Site.objects.get_current()
root_nodes = cls.objects.root_nodes().filter(site=site)
path = path.lstrip("/") path = path.lstrip("/")
no_paths = root_nodes.count()
if no_paths == 0:
raise NoRootURL
if no_paths > 1:
raise MultipleRootURLs
# Root page requested # Root page requested
if not path: if not path:
return root_nodes[0] return cls.root()
slugs = path.split('/') slugs = path.split('/')
level = 1 level = 1
parent = root_nodes[0] parent = cls.root()
for slug in slugs: for slug in slugs:
if settings.URL_CASE_SENSITIVE: if settings.URL_CASE_SENSITIVE:
parent = parent.get_children.get(slug=slug) parent = parent.get_children.get(slug=slug)
...@@ -100,7 +111,39 @@ class URLPath(MPTTModel): ...@@ -100,7 +111,39 @@ class URLPath(MPTTModel):
@property @property
def article(self): def article(self):
try: try:
return self.articles.all()[0] return self.articles.all()[0].article
except IndexError: except IndexError:
return None return None
######################################################
# SIGNAL HANDLERS
######################################################
def on_article_delete(instance, *args, **kwargs):
# If an article is deleted, then throw out its URLPaths
# But move all descendants to a lost-and-found node.
site = Site.objects.get_current()
try:
lost_and_found = URLPath.objects.get(slug=settings.LOST_AND_FOUND_SLUG,
parent=URLPath.root(),
site=site)
except URLPath.DoesNotExist:
lost_and_found = URLPath.objects.create(slug=settings.LOST_AND_FOUND_SLUG,
parent=URLPath.root(),
site=site,)
article = Article(title=_(u"Lost and found"),
group_read = True,
group_write = False,
other_read = False,
other_write = False)
article.add_revision(ArticleRevision(
content=_(u'Articles who lost their parents'
'===============================')))
for urlpath in URLPath.objects.filter(articles__article=article, site=site):
for child in urlpath.get_children():
child.move_to(lost_and_found)
pre_delete.connect(on_article_delete, Article)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment