From: Radek Czajka Date: Fri, 19 Aug 2011 11:24:53 +0000 (+0200) Subject: Merge branch 'master' into with-dvcs X-Git-Url: https://git.mdrn.pl/redakcja.git/commitdiff_plain/fdd62169ba22c4c1be2f2306b5339eadd74ffb6d?hp=9053b4410f14a2ceb2b14f9af42dc46c742dc196 Merge branch 'master' into with-dvcs --- diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..56b9c425 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/librarian"] + path = lib/librarian + url = git://github.com/fnp/librarian.git diff --git a/apps/apiclient/__init__.py b/apps/apiclient/__init__.py new file mode 100644 index 00000000..7913ac3c --- /dev/null +++ b/apps/apiclient/__init__.py @@ -0,0 +1,46 @@ +import urllib + +from django.utils import simplejson +import oauth2 + +from apiclient.models import OAuthConnection +from apiclient.settings import WL_CONSUMER_KEY, WL_CONSUMER_SECRET, WL_API_URL + + +if WL_CONSUMER_KEY and WL_CONSUMER_SECRET: + wl_consumer = oauth2.Consumer(WL_CONSUMER_KEY, WL_CONSUMER_SECRET) +else: + wl_consumer = None + + +class ApiError(BaseException): + pass + + +class NotAuthorizedError(BaseException): + pass + + +def api_call(user, path, data=None): + # what if not verified? + conn = OAuthConnection.get(user) + if not conn.access: + raise NotAuthorizedError("No WL authorization for user %s." % user) + token = oauth2.Token(conn.token, conn.token_secret) + client = oauth2.Client(wl_consumer, token) + if data is not None: + resp, content = client.request( + "%s%s.json" % (WL_API_URL, path), + method="POST", + body=urllib.urlencode(data)) + else: + resp, content = client.request( + "%s%s.json" % (WL_API_URL, path)) + status = resp['status'] + if status == '200': + return simplejson.loads(content) + elif status.startswith('2'): + return + else: + raise ApiError("WL API call error") + diff --git a/apps/apiclient/migrations/0001_initial.py b/apps/apiclient/migrations/0001_initial.py new file mode 100644 index 00000000..4af28a52 --- /dev/null +++ b/apps/apiclient/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'OAuthConnection' + db.create_table('apiclient_oauthconnection', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True)), + ('access', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('token', self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True)), + ('token_secret', self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True)), + )) + db.send_create_signal('apiclient', ['OAuthConnection']) + + + def backwards(self, orm): + + # Deleting model 'OAuthConnection' + db.delete_table('apiclient_oauthconnection') + + + models = { + 'apiclient.oauthconnection': { + 'Meta': {'object_name': 'OAuthConnection'}, + 'access': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'token': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'token_secret': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['apiclient'] diff --git a/apps/apiclient/migrations/__init__.py b/apps/apiclient/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/apiclient/models.py b/apps/apiclient/models.py new file mode 100644 index 00000000..d3c8f620 --- /dev/null +++ b/apps/apiclient/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.contrib.auth.models import User + + +class OAuthConnection(models.Model): + user = models.OneToOneField(User) + access = models.BooleanField(default=False) + token = models.CharField(max_length=64, null=True, blank=True) + token_secret = models.CharField(max_length=64, null=True, blank=True) + + @classmethod + def get(cls, user): + try: + return cls.objects.get(user=user) + except cls.DoesNotExist: + o = cls(user=user) + o.save() + return o + + diff --git a/apps/apiclient/settings.py b/apps/apiclient/settings.py new file mode 100755 index 00000000..5fbf18ee --- /dev/null +++ b/apps/apiclient/settings.py @@ -0,0 +1,15 @@ +from django.conf import settings + + +WL_CONSUMER_KEY = getattr(settings, 'APICLIENT_WL_CONSUMER_KEY', None) +WL_CONSUMER_SECRET = getattr(settings, 'APICLIENT_WL_CONSUMER_SECRET', None) + +WL_API_URL = getattr(settings, 'APICLIENT_WL_API_URL', + 'http://www.wolnelektury.pl/api/') + +WL_REQUEST_TOKEN_URL = getattr(settings, 'APICLIENT_WL_REQUEST_TOKEN_URL', + WL_API_URL + 'oauth/request_token/') +WL_ACCESS_TOKEN_URL = getattr(settings, 'APICLIENT_WL_ACCESS_TOKEN_URL', + WL_API_URL + 'oauth/access_token/') +WL_AUTHORIZE_URL = getattr(settings, 'APICLIENT_WL_AUTHORIZE_URL', + WL_API_URL + 'oauth/authorize/') diff --git a/apps/apiclient/tests.py b/apps/apiclient/tests.py new file mode 100644 index 00000000..2247054b --- /dev/null +++ b/apps/apiclient/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these with more appropriate tests for your application. +""" + +from django.test import TestCase + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/apps/apiclient/urls.py b/apps/apiclient/urls.py new file mode 100755 index 00000000..5e54965e --- /dev/null +++ b/apps/apiclient/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('apiclient.views', + url(r'^oauth/$', 'oauth', name='users_oauth'), + url(r'^oauth_callback/$', 'oauth_callback', name='users_oauth_callback'), +) diff --git a/apps/apiclient/views.py b/apps/apiclient/views.py new file mode 100644 index 00000000..f8515904 --- /dev/null +++ b/apps/apiclient/views.py @@ -0,0 +1,60 @@ +import cgi + +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect, HttpResponse +import oauth2 + +from apiclient.models import OAuthConnection +from apiclient import wl_consumer +from apiclient.settings import (WL_REQUEST_TOKEN_URL, WL_ACCESS_TOKEN_URL, + WL_AUTHORIZE_URL) + + +@login_required +def oauth(request): + if wl_consumer is None: + return HttpResponse("OAuth consumer not configured.") + + client = oauth2.Client(wl_consumer) + resp, content = client.request(WL_REQUEST_TOKEN_URL) + if resp['status'] != '200': + raise Exception("Invalid response %s." % resp['status']) + + request_token = dict(cgi.parse_qsl(content)) + + conn = OAuthConnection.get(request.user) + # this might reset existing auth! + conn.access = False + conn.token = request_token['oauth_token'] + conn.token_secret = request_token['oauth_token_secret'] + conn.save() + + url = "%s?oauth_token=%s&oauth_callback=%s" % ( + WL_AUTHORIZE_URL, + request_token['oauth_token'], + request.build_absolute_uri(reverse("users_oauth_callback")), + ) + + return HttpResponseRedirect(url) + + +@login_required +def oauth_callback(request): + if wl_consumer is None: + return HttpResponse("OAuth consumer not configured.") + + oauth_verifier = request.GET.get('oauth_verifier') + conn = OAuthConnection.get(request.user) + token = oauth2.Token(conn.token, conn.token_secret) + token.set_verifier(oauth_verifier) + client = oauth2.Client(wl_consumer, token) + resp, content = client.request(WL_ACCESS_TOKEN_URL, method="POST") + access_token = dict(cgi.parse_qsl(content)) + + conn.access = True + conn.token = access_token['oauth_token'] + conn.token_secret = access_token['oauth_token_secret'] + conn.save() + + return HttpResponseRedirect('/') diff --git a/apps/catalogue/__init__.py b/apps/catalogue/__init__.py new file mode 100644 index 00000000..c53f0e73 --- /dev/null +++ b/apps/catalogue/__init__.py @@ -0,0 +1 @@ + # pragma: no cover diff --git a/apps/catalogue/admin.py b/apps/catalogue/admin.py new file mode 100644 index 00000000..70e20d20 --- /dev/null +++ b/apps/catalogue/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from catalogue import models + +class BookAdmin(admin.ModelAdmin): + prepopulated_fields = {'slug': ['title']} + + +admin.site.register(models.Book, BookAdmin) +admin.site.register(models.Chunk) + +admin.site.register(models.Chunk.tag_model) diff --git a/apps/catalogue/constants.py b/apps/catalogue/constants.py new file mode 100644 index 00000000..d75d6b4b --- /dev/null +++ b/apps/catalogue/constants.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +TRIM_BEGIN = " TRIM_BEGIN " +TRIM_END = " TRIM_END " + +MASTERS = ['powiesc', + 'opowiadanie', + 'liryka_l', + 'liryka_lp', + 'dramat_wierszowany_l', + 'dramat_wierszowany_lp', + 'dramat_wspolczesny', + ] diff --git a/apps/catalogue/fixtures/stages.json b/apps/catalogue/fixtures/stages.json new file mode 100644 index 00000000..5a46ec04 --- /dev/null +++ b/apps/catalogue/fixtures/stages.json @@ -0,0 +1,83 @@ +[ + { + "pk": 1, + "model": "catalogue.chunktag", + "fields": { + "ordering": 1, + "name": "Autokorekta", + "slug": "first_correction" + } + }, + { + "pk": 2, + "model": "catalogue.chunktag", + "fields": { + "ordering": 2, + "name": "Tagowanie", + "slug": "tagging" + } + }, + { + "pk": 3, + "model": "catalogue.chunktag", + "fields": { + "ordering": 3, + "name": "Korekta", + "slug": "proofreading" + } + }, + { + "pk": 4, + "model": "catalogue.chunktag", + "fields": { + "ordering": 4, + "name": "Sprawdzenie przypis\u00f3w \u017ar\u00f3d\u0142a", + "slug": "annotation-proofreading" + } + }, + { + "pk": 5, + "model": "catalogue.chunktag", + "fields": { + "ordering": 5, + "name": "Uwsp\u00f3\u0142cze\u015bnienie", + "slug": "modernisation" + } + }, + { + "pk": 6, + "model": "catalogue.chunktag", + "fields": { + "ordering": 6, + "name": "Przypisy", + "slug": "annotations" + } + }, + { + "pk": 7, + "model": "catalogue.chunktag", + "fields": { + "ordering": 7, + "name": "Motywy", + "slug": "themes" + } + }, + { + "pk": 8, + "model": "catalogue.chunktag", + "fields": { + "ordering": 8, + "name": "Ostateczna redakcja literacka", + "slug": "editor-proofreading" + } + }, + { + "pk": 9, + "model": "catalogue.chunktag", + "fields": { + "ordering": 9, + "name": "Ostateczna redakcja techniczna", + "slug": "technical-editor-proofreading" + } + } +] diff --git a/apps/catalogue/forms.py b/apps/catalogue/forms.py new file mode 100644 index 00000000..6a56e764 --- /dev/null +++ b/apps/catalogue/forms.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# +# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from django.contrib.auth.models import User +from django.db.models import Count +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from catalogue.constants import MASTERS +from catalogue.models import Book, Chunk + +class DocumentCreateForm(forms.ModelForm): + """ + Form used for creating new documents. + """ + file = forms.FileField(required=False) + text = forms.CharField(required=False, widget=forms.Textarea) + + class Meta: + model = Book + exclude = ['gallery', 'parent', 'parent_number'] + prepopulated_fields = {'slug': ['title']} + + def clean(self): + super(DocumentCreateForm, self).clean() + file = self.cleaned_data['file'] + + if file is not None: + try: + self.cleaned_data['text'] = file.read().decode('utf-8') + except UnicodeDecodeError: + raise forms.ValidationError("Text file must be UTF-8 encoded.") + + if not self.cleaned_data["text"]: + raise forms.ValidationError("You must either enter text or upload a file") + + return self.cleaned_data + + +class DocumentsUploadForm(forms.Form): + """ + Form used for uploading new documents. + """ + file = forms.FileField(required=True, label=_('ZIP file')) + + def clean(self): + file = self.cleaned_data['file'] + + import zipfile + try: + z = self.cleaned_data['zip'] = zipfile.ZipFile(file) + except zipfile.BadZipfile: + raise forms.ValidationError("Should be a ZIP file.") + if z.testzip(): + raise forms.ValidationError("ZIP file corrupt.") + + return self.cleaned_data + + +class ChunkForm(forms.ModelForm): + """ + Form used for editing a chunk. + """ + user = forms.ModelChoiceField(queryset= + User.objects.annotate(count=Count('chunk')). + order_by('-count', 'last_name', 'first_name'), required=False) + + + class Meta: + model = Chunk + exclude = ['number'] + + def clean_slug(self): + slug = self.cleaned_data['slug'] + try: + chunk = Chunk.objects.get(book=self.instance.book, slug=slug) + except Chunk.DoesNotExist: + return slug + if chunk == self.instance: + return slug + raise forms.ValidationError(_('Chunk with this slug already exists')) + + +class ChunkAddForm(ChunkForm): + """ + Form used for adding a chunk to a document. + """ + + def clean_slug(self): + slug = self.cleaned_data['slug'] + try: + user = Chunk.objects.get(book=self.instance.book, slug=slug) + except Chunk.DoesNotExist: + return slug + raise forms.ValidationError(_('Chunk with this slug already exists')) + + +class BookAppendForm(forms.Form): + """ + Form for appending a book to another book. + It means moving all chunks from book A to book B and deleting A. + """ + + append_to = forms.ModelChoiceField(queryset=Book.objects.all(), + label=_("Append to")) + + +class BookForm(forms.ModelForm): + """ + Form used for editing a Book. + """ + + class Meta: + model = Book + + +class ChooseMasterForm(forms.Form): + """ + Form used for fixing the chunks in a book. + """ + + master = forms.ChoiceField(choices=((m, m) for m in MASTERS)) diff --git a/apps/catalogue/helpers.py b/apps/catalogue/helpers.py new file mode 100644 index 00000000..c9dc0bd9 --- /dev/null +++ b/apps/catalogue/helpers.py @@ -0,0 +1,65 @@ +from functools import wraps + +from django.db.models import Count + + +def active_tab(tab): + """ + View decorator, which puts tab info on a request. + """ + def wrapper(f): + @wraps(f) + def wrapped(request, *args, **kwargs): + request.catalogue_active_tab = tab + return f(request, *args, **kwargs) + return wrapped + return wrapper + + +class ChunksList(object): + def __init__(self, chunk_qs): + self.chunk_qs = chunk_qs.annotate( + book_length=Count('book__chunk')).select_related( + 'book', 'stage__name', + 'user') + + self.book_qs = chunk_qs.values('book_id') + + def __getitem__(self, key): + if isinstance(key, slice): + return self.get_slice(key) + elif isinstance(key, int): + return self.get_slice(slice(key, key+1))[0] + else: + raise TypeError('Unsupported list index. Must be a slice or an int.') + + def __len__(self): + return self.book_qs.count() + + def get_slice(self, slice_): + book_ids = [x['book_id'] for x in self.book_qs[slice_]] + chunk_qs = self.chunk_qs.filter(book__in=book_ids) + + chunks_list = [] + book = None + for chunk in chunk_qs: + if chunk.book != book: + book = chunk.book + chunks_list.append(ChoiceChunks(book, [chunk], chunk.book_length)) + else: + chunks_list[-1].chunks.append(chunk) + return chunks_list + + +class ChoiceChunks(object): + """ + Associates the given chunks iterable for a book. + """ + + chunks = None + + def __init__(self, book, chunks, book_length): + self.book = book + self.chunks = chunks + self.book_length = book_length + diff --git a/apps/catalogue/locale/pl/LC_MESSAGES/django.mo b/apps/catalogue/locale/pl/LC_MESSAGES/django.mo new file mode 100644 index 00000000..f841945b Binary files /dev/null and b/apps/catalogue/locale/pl/LC_MESSAGES/django.mo differ diff --git a/apps/catalogue/locale/pl/LC_MESSAGES/django.po b/apps/catalogue/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 00000000..c760f3a7 --- /dev/null +++ b/apps/catalogue/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,508 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Platforma Redakcyjna\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2011-06-21 13:41+0200\n" +"PO-Revision-Date: 2011-06-21 13:46+0100\n" +"Last-Translator: Radek Czajka \n" +"Language-Team: Fundacja Nowoczesna Polska \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: forms.py:32 +msgid "Publishable" +msgstr "Gotowe do publikacji" + +#: forms.py:68 +msgid "ZIP file" +msgstr "Plik ZIP" + +#: forms.py:99 +#: forms.py:137 +msgid "Author" +msgstr "Autor" + +#: forms.py:100 +#: forms.py:138 +msgid "Your name" +msgstr "Imię i nazwisko" + +#: forms.py:105 +#: forms.py:143 +msgid "Author's email" +msgstr "E-mail autora" + +#: forms.py:106 +#: forms.py:144 +msgid "Your email address, so we can show a gravatar :)" +msgstr "Adres e-mail, żebyśmy mogli pokazać gravatar :)" + +#: forms.py:112 +#: forms.py:150 +msgid "Your comments" +msgstr "Twój komentarz" + +#: forms.py:113 +msgid "Describe changes you made." +msgstr "Opisz swoje zmiany" + +#: forms.py:119 +msgid "Completed" +msgstr "Ukończono" + +#: forms.py:120 +msgid "If you completed a life cycle stage, select it." +msgstr "Jeśli został ukończony etap prac, wskaż go." + +#: forms.py:151 +msgid "Describe the reason for reverting." +msgstr "Opisz powód przywrócenia." + +#: forms.py:176 +#: forms.py:190 +msgid "Chunk with this slug already exists" +msgstr "Część z tym slugiem już istnieje" + +#: forms.py:202 +msgid "Append to" +msgstr "Dołącz do" + +#: models.py:25 +msgid "title" +msgstr "tytuł" + +#: models.py:26 +msgid "slug" +msgstr "" + +#: models.py:27 +msgid "scan gallery name" +msgstr "nazwa galerii skanów" + +#: models.py:29 +msgid "parent" +msgstr "rodzic" + +#: models.py:30 +msgid "parent number" +msgstr "numeracja rodzica" + +#: models.py:40 +msgid "book" +msgstr "książka" + +#: models.py:41 +msgid "books" +msgstr "książki" + +#: models.py:206 +msgid "name" +msgstr "nazwa" + +#: models.py:210 +msgid "theme" +msgstr "motyw" + +#: models.py:211 +msgid "themes" +msgstr "motywy" + +#: views.py:241 +#, python-format +msgid "Slug already used for %s" +msgstr "Slug taki sam jak dla pliku %s" + +#: views.py:243 +msgid "Slug already used in repository." +msgstr "Dokument o tym slugu już istnieje w repozytorium." + +#: views.py:249 +msgid "File should be UTF-8 encoded." +msgstr "Plik powinien mieć kodowanie UTF-8." + +#: views.py:655 +msgid "Tag added" +msgstr "Dodano tag" + +#: views.py:677 +msgid "Revision marked" +msgstr "Wersja oznaczona" + +#: views.py:679 +msgid "Nothing changed" +msgstr "Nic nie uległo zmianie" + +#: templates/wiki/base.html:9 +msgid "Platforma Redakcyjna" +msgstr "" + +#: templates/wiki/book_append_to.html:8 +msgid "Append book" +msgstr "Dołącz książkę" + +#: templates/wiki/book_detail.html:6 +#: templates/wiki/book_detail.html.py:46 +msgid "edit" +msgstr "edytuj" + +#: templates/wiki/book_detail.html:16 +msgid "add basic document structure" +msgstr "dodaj podstawową strukturę dokumentu" + +#: templates/wiki/book_detail.html:20 +msgid "change master tag to" +msgstr "zmień tak master na" + +#: templates/wiki/book_detail.html:24 +msgid "add begin trimming tag" +msgstr "dodaj początkowy ogranicznik" + +#: templates/wiki/book_detail.html:28 +msgid "add end trimming tag" +msgstr "dodaj końcowy ogranicznik" + +#: templates/wiki/book_detail.html:34 +msgid "unstructured text" +msgstr "tekst bez struktury" + +#: templates/wiki/book_detail.html:38 +msgid "unknown XML" +msgstr "nieznany XML" + +#: templates/wiki/book_detail.html:42 +msgid "broken document" +msgstr "uszkodzony dokument" + +#: templates/wiki/book_detail.html:60 +msgid "Apply fixes" +msgstr "Wykonaj zmiany" + +#: templates/wiki/book_detail.html:66 +msgid "Append to other book" +msgstr "Dołącz do innej książki" + +#: templates/wiki/book_detail.html:68 +msgid "Last published" +msgstr "Ostatnio opublikowano" + +#: templates/wiki/book_detail.html:72 +msgid "Full XML" +msgstr "Pełny XML" + +#: templates/wiki/book_detail.html:73 +msgid "HTML version" +msgstr "Wersja HTML" + +#: templates/wiki/book_detail.html:74 +msgid "TXT version" +msgstr "Wersja TXT" + +#: templates/wiki/book_detail.html:76 +msgid "EPUB version" +msgstr "Wersja EPUB" + +#: templates/wiki/book_detail.html:77 +msgid "PDF version" +msgstr "Wersja PDF" + +#: templates/wiki/book_detail.html:90 +#: templates/wiki/tabs/summary_view.html:30 +msgid "Publish" +msgstr "Opublikuj" + +#: templates/wiki/book_detail.html:94 +msgid "This book cannot be published yet" +msgstr "Ta książka nie może jeszcze zostać opublikowana" + +#: templates/wiki/book_edit.html:8 +#: templates/wiki/chunk_edit.html:8 +#: templates/wiki/document_details_base.html:35 +#: templates/wiki/pubmark_dialog.html:15 +#: templates/wiki/tag_dialog.html:15 +msgid "Save" +msgstr "Zapisz" + +#: templates/wiki/chunk_add.html:8 +msgid "Add chunk" +msgstr "Dodaj część" + +#: templates/wiki/diff_table.html:5 +msgid "Old version" +msgstr "Stara wersja" + +#: templates/wiki/diff_table.html:6 +msgid "New version" +msgstr "Nowa wersja" + +#: templates/wiki/document_create_missing.html:8 +msgid "Create document" +msgstr "Utwórz dokument" + +#: templates/wiki/document_details.html:32 +msgid "Click to open/close gallery" +msgstr "Kliknij, aby (ro)zwinąć galerię" + +#: templates/wiki/document_details_base.html:31 +msgid "Help" +msgstr "Pomoc" + +#: templates/wiki/document_details_base.html:33 +msgid "Version" +msgstr "Wersja" + +#: templates/wiki/document_details_base.html:33 +msgid "Unknown" +msgstr "nieznana" + +#: templates/wiki/document_details_base.html:36 +msgid "Save attempt in progress" +msgstr "Trwa zapisywanie" + +#: templates/wiki/document_details_base.html:37 +msgid "There is a newer version of this document!" +msgstr "Istnieje nowsza wersja tego dokumentu!" + +#: templates/wiki/document_list.html:31 +msgid "Clear filter" +msgstr "Wyczyść filtr" + +#: templates/wiki/document_list.html:46 +msgid "No books found." +msgstr "Nie znaleziono książek." + +#: templates/wiki/document_list.html:89 +msgid "Your last edited documents" +msgstr "Twoje ostatnie edycje" + +#: templates/wiki/document_upload.html:8 +msgid "Bulk documents upload" +msgstr "Hurtowe dodawanie dokumentów" + +#: templates/wiki/document_upload.html:11 +msgid "Please submit a ZIP with UTF-8 encoded XML files. Files not ending with .xml will be ignored." +msgstr "Proszę wskazać archiwum ZIP z plikami XML w kodowaniu UTF-8. Pliki nie kończące się na .xml zostaną zignorowane." + +#: templates/wiki/document_upload.html:16 +#: templatetags/wiki.py:36 +msgid "Upload" +msgstr "Załaduj" + +#: templates/wiki/document_upload.html:23 +msgid "There have been some errors. No files have been added to the repository." +msgstr "Wystąpiły błędy. Żadne pliki nie zostały dodane do repozytorium." + +#: templates/wiki/document_upload.html:24 +msgid "Offending files" +msgstr "Błędne pliki" + +#: templates/wiki/document_upload.html:32 +msgid "Correct files" +msgstr "Poprawne pliki" + +#: templates/wiki/document_upload.html:43 +msgid "Files have been successfully uploaded to the repository." +msgstr "Pliki zostały dodane do repozytorium." + +#: templates/wiki/document_upload.html:44 +msgid "Uploaded files" +msgstr "Dodane pliki" + +#: templates/wiki/document_upload.html:54 +msgid "Skipped files" +msgstr "Pominięte pliki" + +#: templates/wiki/document_upload.html:55 +msgid "Files skipped due to no .xml extension" +msgstr "Pliki pominięte z powodu braku rozszerzenia .xml." + +#: templates/wiki/pubmark_dialog.html:16 +#: templates/wiki/revert_dialog.html:39 +#: templates/wiki/tag_dialog.html:16 +msgid "Cancel" +msgstr "Anuluj" + +#: templates/wiki/revert_dialog.html:38 +msgid "Revert" +msgstr "Przywróć" + +#: templates/wiki/user_list.html:7 +#: templatetags/wiki.py:33 +msgid "Users" +msgstr "Użytkownicy" + +#: templates/wiki/tabs/annotations_view.html:9 +msgid "all" +msgstr "wszystkie" + +#: templates/wiki/tabs/annotations_view_item.html:3 +msgid "Annotations" +msgstr "Przypisy" + +#: templates/wiki/tabs/gallery_view.html:7 +msgid "Previous" +msgstr "Poprzednie" + +#: templates/wiki/tabs/gallery_view.html:13 +msgid "Next" +msgstr "Następne" + +#: templates/wiki/tabs/gallery_view.html:15 +msgid "Zoom in" +msgstr "Powiększ" + +#: templates/wiki/tabs/gallery_view.html:16 +msgid "Zoom out" +msgstr "Zmniejsz" + +#: templates/wiki/tabs/gallery_view_item.html:3 +msgid "Gallery" +msgstr "Galeria" + +#: templates/wiki/tabs/history_view.html:5 +msgid "Compare versions" +msgstr "Porównaj wersje" + +#: templates/wiki/tabs/history_view.html:7 +msgid "Mark for publishing" +msgstr "Oznacz do publikacji" + +#: templates/wiki/tabs/history_view.html:9 +msgid "Revert document" +msgstr "Przywróć wersję" + +#: templates/wiki/tabs/history_view.html:12 +msgid "View version" +msgstr "Zobacz wersję" + +#: templates/wiki/tabs/history_view_item.html:3 +msgid "History" +msgstr "Historia" + +#: templates/wiki/tabs/search_view.html:3 +#: templates/wiki/tabs/search_view.html:5 +msgid "Search" +msgstr "Szukaj" + +#: templates/wiki/tabs/search_view.html:8 +msgid "Replace with" +msgstr "Zamień na" + +#: templates/wiki/tabs/search_view.html:10 +msgid "Replace" +msgstr "Zamień" + +#: templates/wiki/tabs/search_view.html:13 +msgid "Options" +msgstr "Opcje" + +#: templates/wiki/tabs/search_view.html:15 +msgid "Case sensitive" +msgstr "Rozróżniaj wielkość liter" + +#: templates/wiki/tabs/search_view.html:17 +msgid "From cursor" +msgstr "Zacznij od kursora" + +#: templates/wiki/tabs/search_view_item.html:3 +msgid "Search and replace" +msgstr "Znajdź i zamień" + +#: templates/wiki/tabs/source_editor_item.html:5 +msgid "Source code" +msgstr "Kod źródłowy" + +#: templates/wiki/tabs/summary_view.html:9 +msgid "Title" +msgstr "Tytuł" + +#: templates/wiki/tabs/summary_view.html:14 +msgid "Document ID" +msgstr "ID dokumentu" + +#: templates/wiki/tabs/summary_view.html:18 +msgid "Current version" +msgstr "Aktualna wersja" + +#: templates/wiki/tabs/summary_view.html:21 +msgid "Last edited by" +msgstr "Ostatnio edytowane przez" + +#: templates/wiki/tabs/summary_view.html:25 +msgid "Link to gallery" +msgstr "Link do galerii" + +#: templates/wiki/tabs/summary_view_item.html:3 +msgid "Summary" +msgstr "Podsumowanie" + +#: templates/wiki/tabs/wysiwyg_editor.html:9 +msgid "Insert theme" +msgstr "Wstaw motyw" + +#: templates/wiki/tabs/wysiwyg_editor.html:12 +msgid "Insert annotation" +msgstr "Wstaw przypis" + +#: templates/wiki/tabs/wysiwyg_editor_item.html:3 +msgid "Visual editor" +msgstr "Edytor wizualny" + +#: templatetags/wiki.py:30 +msgid "Assigned to me" +msgstr "Przypisane do mnie" + +#: templatetags/wiki.py:32 +msgid "Unassigned" +msgstr "Nie przypisane" + +#: templatetags/wiki.py:34 +msgid "All" +msgstr "Wszystkie" + +#: templatetags/wiki.py:35 +msgid "Add" +msgstr "Dodaj" + +#: templatetags/wiki.py:39 +msgid "Admin" +msgstr "Administracja" + +#~ msgid "First correction" +#~ msgstr "Autokorekta" + +#~ msgid "Tagging" +#~ msgstr "Tagowanie" + +#~ msgid "Initial Proofreading" +#~ msgstr "Korekta" + +#~ msgid "Annotation Proofreading" +#~ msgstr "Sprawdzenie przypisów źródła" + +#~ msgid "Modernisation" +#~ msgstr "Uwspółcześnienie" + +#~ msgid "Themes" +#~ msgstr "Motywy" + +#~ msgid "Editor's Proofreading" +#~ msgstr "Ostateczna redakcja literacka" + +#~ msgid "Technical Editor's Proofreading" +#~ msgstr "Ostateczna redakcja techniczna" + +#~ msgid "Finished stage: %s" +#~ msgstr "Ukończony etap: %s" + +#~ msgid "Refresh" +#~ msgstr "Odśwież" + +#~ msgid "Insert special character" +#~ msgstr "Wstaw znak specjalny" diff --git a/apps/catalogue/management/__init__.py b/apps/catalogue/management/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/catalogue/management/commands/__init__.py b/apps/catalogue/management/commands/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/catalogue/management/commands/assign_from_redmine.py b/apps/catalogue/management/commands/assign_from_redmine.py new file mode 100755 index 00000000..9f7b12d4 --- /dev/null +++ b/apps/catalogue/management/commands/assign_from_redmine.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- + +import csv +from optparse import make_option +import re +import sys +import urllib +import urllib2 + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from django.core.management.color import color_style +from django.db import transaction + +from slughifi import slughifi +from catalogue.models import Chunk + + +REDMINE_CSV = 'http://redmine.nowoczesnapolska.org.pl/projects/wl-publikacje/issues.csv' +REDAKCJA_URL = 'http://redakcja.wolnelektury.pl/documents/' + + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('-r', '--redakcja', dest='redakcja', metavar='URL', + help='Base URL of Redakcja documents', + default=REDAKCJA_URL), + make_option('-q', '--quiet', action='store_false', dest='verbose', default=True, + help='Less output'), + make_option('-f', '--force', action='store_true', dest='force', default=False, + help='Force assignment overwrite'), + ) + help = 'Imports ticket assignments from Redmine.' + args = '[redmine-csv-url]' + + def handle(self, *redmine_csv, **options): + + self.style = color_style() + + redakcja = options.get('redakcja') + verbose = options.get('verbose') + force = options.get('force') + + if not redmine_csv: + if verbose: + print "Using default Redmine CSV URL:", REDMINE_CSV + redmine_csv = REDMINE_CSV + + # Start transaction management. + transaction.commit_unless_managed() + transaction.enter_transaction_management() + transaction.managed(True) + + redakcja_link = re.compile(re.escape(redakcja) + r'([-_.:?&%/a-zA-Z0-9]*)') + + all_tickets = 0 + all_chunks = 0 + done_tickets = 0 + done_chunks = 0 + empty_users = 0 + unknown_users = {} + unknown_books = [] + forced = [] + + if verbose: + print 'Downloading CSV file' + for r in csv.reader(urllib2.urlopen(redmine_csv)): + if r[0] == '#': + continue + all_tickets += 1 + + username = r[6] + if not username: + if verbose: + print "Empty user, skipping" + empty_users += 1 + continue + + first_name, last_name = unicode(username, 'utf-8').rsplit(u' ', 1) + try: + user = User.objects.get(first_name=first_name, last_name=last_name) + except User.DoesNotExist: + print self.style.ERROR('Unknown user: ' + username) + unknown_users.setdefault(username, 0) + unknown_users[username] += 1 + continue + + ticket_done = False + for fname in redakcja_link.findall(r[-1]): + fname = unicode(urllib.unquote(fname), 'utf-8', 'ignore') + if fname.endswith('.xml'): + fname = fname[:-4] + fname = fname.replace(' ', '_') + fname = slughifi(fname) + + chunks = Chunk.objects.filter(book__slug=fname) + if not chunks: + print self.style.ERROR('Unknown book: ' + fname) + unknown_books.append(fname) + continue + all_chunks += chunks.count() + + for chunk in chunks: + if chunk.user: + if chunk.user == user: + continue + else: + forced.append((chunk, chunk.user, user)) + if force: + print self.style.WARNING( + '%s assigned to %s, forcing change to %s.' % + (chunk.pretty_name(), chunk.user, user)) + else: + print self.style.WARNING( + '%s assigned to %s not to %s, skipping.' % + (chunk.pretty_name(), chunk.user, user)) + continue + chunk.user = user + chunk.save() + ticket_done = True + done_chunks += 1 + + if ticket_done: + done_tickets += 1 + + + # Print results + print + print "Results:" + print "Assignments imported from %d/%d tickets to %d/%d relevalt chunks." % ( + done_tickets, all_tickets, done_chunks, all_chunks) + if empty_users: + print "%d tickets were unassigned." % empty_users + if forced: + print "%d assignments conficts (%s):" % ( + len(forced), "changed" if force else "left") + for chunk, orig, user in forced: + print " %s: \t%s \t-> %s" % ( + chunk.pretty_name(), orig.username, user.username) + if unknown_books: + print "%d unknown books:" % len(unknown_books) + for fname in unknown_books: + print " %s" % fname + if unknown_users: + print "%d unknown users:" % len(unknown_users) + for name in unknown_users: + print " %s (%d tickets)" % (name, unknown_users[name]) + print + + + transaction.commit() + transaction.leave_transaction_management() + diff --git a/apps/catalogue/migrations/0001_initial.py b/apps/catalogue/migrations/0001_initial.py new file mode 100644 index 00000000..4ebde478 --- /dev/null +++ b/apps/catalogue/migrations/0001_initial.py @@ -0,0 +1,365 @@ +# encoding: utf-8 +import datetime +import os.path +import cPickle +import re +import urllib + +from django.conf import settings +from django.db import models +from mercurial import mdiff, hg, ui +from south.db import db +from south.v2 import SchemaMigration + +from slughifi import slughifi + +META_REGEX = re.compile(r'\s*', re.DOTALL | re.MULTILINE) +STAGE_TAGS_RE = re.compile(r'^#stage-finished: (.*)$', re.MULTILINE) +AUTHOR_RE = re.compile(r'\s*(.*?)\s*<(.*)>\s*') + + +def urlunquote(url): + """Unqotes URL + + # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84') + # u'Za\u017c\xf3\u0142\u0107_g\u0119\u015bl\u0105 ja\u017a\u0144' + """ + return unicode(urllib.unquote(url), 'utf-8', 'ignore') + + +def split_name(name): + parts = name.split('__') + return parts + + +def file_to_title(fname): + """ Returns a title-like version of a filename. """ + parts = (p.replace('_', ' ').title() for p in fname.split('__')) + return ' / '.join(parts) + + +def make_patch(src, dst): + if isinstance(src, unicode): + src = src.encode('utf-8') + if isinstance(dst, unicode): + dst = dst.encode('utf-8') + return cPickle.dumps(mdiff.textdiff(src, dst)) + + +def plain_text(text): + return re.sub(META_REGEX, '', text, 1) + + +def gallery(slug, text): + result = {} + + m = re.match(META_REGEX, text) + if m: + for line in m.group(1).split('\n'): + try: + k, v = line.split(':', 1) + result[k.strip()] = v.strip() + except ValueError: + continue + + gallery = result.get('gallery', slughifi(slug)) + + if gallery.startswith('/'): + gallery = os.path.basename(gallery) + + return gallery + + +def migrate_file_from_hg(orm, fname, entry): + fname = urlunquote(fname) + print fname + if fname.endswith('.xml'): + fname = fname[:-4] + title = file_to_title(fname) + fname = slughifi(fname) + # create all the needed objects + # what if it already exists? + book = orm.Book.objects.create( + title=title, + slug=fname) + chunk = orm.Chunk.objects.create( + book=book, + number=1, + slug='1') + head = orm.ChunkChange.objects.create( + tree=chunk, + revision=-1, + patch=make_patch('', ''), + created_at=datetime.datetime.fromtimestamp(entry.filectx(0).date()[0]), + description='' + ) + chunk.head = head + try: + chunk.stage = orm.ChunkTag.objects.order_by('ordering')[0] + except IndexError: + chunk.stage = None + old_data = '' + + maxrev = entry.filerev() + gallery_link = None + + for rev in xrange(maxrev + 1): + fctx = entry.filectx(rev) + data = fctx.data() + gallery_link = gallery(fname, data) + data = plain_text(data) + + # get tags from description + description = fctx.description().decode("utf-8", 'replace') + tags = STAGE_TAGS_RE.findall(description) + tags = [orm.ChunkTag.objects.get(slug=slug.strip()) for slug in tags] + + if tags: + max_ordering = max(tags, key=lambda x: x.ordering).ordering + try: + chunk.stage = orm.ChunkTag.objects.filter(ordering__gt=max_ordering).order_by('ordering')[0] + except IndexError: + chunk.stage = None + + description = STAGE_TAGS_RE.sub('', description) + + author = author_name = author_email = None + author_desc = fctx.user().decode("utf-8", 'replace') + m = AUTHOR_RE.match(author_desc) + if m: + try: + author = orm['auth.User'].objects.get(username=m.group(1), email=m.group(2)) + except orm['auth.User'].DoesNotExist: + author_name = m.group(1) + author_email = m.group(2) + else: + author_name = author_desc + + head = orm.ChunkChange.objects.create( + tree=chunk, + revision=rev + 1, + patch=make_patch(old_data, data), + created_at=datetime.datetime.fromtimestamp(fctx.date()[0]), + description=description, + author=author, + author_name=author_name, + author_email=author_email, + parent=chunk.head + ) + head.tags = tags + chunk.head = head + old_data = data + + chunk.save() + if gallery_link: + book.gallery = gallery_link + book.save() + + +def migrate_from_hg(orm): + try: + hg_path = settings.WIKI_REPOSITORY_PATH + except: + pass + + print 'migrate from', hg_path + repo = hg.repository(ui.ui(), hg_path) + tip = repo['tip'] + for fname in tip: + if fname.startswith('.'): + continue + migrate_file_from_hg(orm, fname, tip[fname]) + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'Book' + db.create_table('catalogue_book', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('title', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=128, db_index=True)), + ('gallery', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('parent', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='children', null=True, to=orm['catalogue.Book'])), + ('parent_number', self.gf('django.db.models.fields.IntegerField')(db_index=True, null=True, blank=True)), + ('last_published', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + )) + db.send_create_signal('catalogue', ['Book']) + + # Adding model 'Chunk' + db.create_table('catalogue_chunk', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('creator', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='created_documents', null=True, to=orm['auth.User'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, blank=True)), + ('book', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['catalogue.Book'])), + ('number', self.gf('django.db.models.fields.IntegerField')()), + ('slug', self.gf('django.db.models.fields.SlugField')(max_length=50, db_index=True)), + ('comment', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('stage', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['catalogue.ChunkTag'], null=True, blank=True)), + ('head', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['catalogue.ChunkChange'], null=True, blank=True)), + )) + db.send_create_signal('catalogue', ['Chunk']) + + # Adding unique constraint on 'Chunk', fields ['book', 'number'] + db.create_unique('catalogue_chunk', ['book_id', 'number']) + + # Adding unique constraint on 'Chunk', fields ['book', 'slug'] + db.create_unique('catalogue_chunk', ['book_id', 'slug']) + + # Adding model 'ChunkTag' + db.create_table('catalogue_chunktag', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=64)), + ('slug', self.gf('django.db.models.fields.SlugField')(db_index=True, max_length=64, unique=True, null=True, blank=True)), + ('ordering', self.gf('django.db.models.fields.IntegerField')()), + )) + db.send_create_signal('catalogue', ['ChunkTag']) + + # Adding model 'ChunkChange' + db.create_table('catalogue_chunkchange', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('author', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, blank=True)), + ('author_name', self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True)), + ('author_email', self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True)), + ('patch', self.gf('django.db.models.fields.TextField')(blank=True)), + ('revision', self.gf('django.db.models.fields.IntegerField')(db_index=True)), + ('parent', self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='children', null=True, blank=True, to=orm['catalogue.ChunkChange'])), + ('merge_parent', self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='merge_children', null=True, blank=True, to=orm['catalogue.ChunkChange'])), + ('description', self.gf('django.db.models.fields.TextField')(default='', blank=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now, db_index=True)), + ('publishable', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('tree', self.gf('django.db.models.fields.related.ForeignKey')(related_name='change_set', to=orm['catalogue.Chunk'])), + )) + db.send_create_signal('catalogue', ['ChunkChange']) + + # Adding unique constraint on 'ChunkChange', fields ['tree', 'revision'] + db.create_unique('catalogue_chunkchange', ['tree_id', 'revision']) + + # Adding M2M table for field tags on 'ChunkChange' + db.create_table('catalogue_chunkchange_tags', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('chunkchange', models.ForeignKey(orm['catalogue.chunkchange'], null=False)), + ('chunktag', models.ForeignKey(orm['catalogue.chunktag'], null=False)) + )) + db.create_unique('catalogue_chunkchange_tags', ['chunkchange_id', 'chunktag_id']) + + if not db.dry_run: + from django.core.management import call_command + call_command("loaddata", "stages.json") + + migrate_from_hg(orm) + + + def backwards(self, orm): + + # Removing unique constraint on 'ChunkChange', fields ['tree', 'revision'] + db.delete_unique('catalogue_chunkchange', ['tree_id', 'revision']) + + # Removing unique constraint on 'Chunk', fields ['book', 'slug'] + db.delete_unique('catalogue_chunk', ['book_id', 'slug']) + + # Removing unique constraint on 'Chunk', fields ['book', 'number'] + db.delete_unique('catalogue_chunk', ['book_id', 'number']) + + # Deleting model 'Book' + db.delete_table('catalogue_book') + + # Deleting model 'Chunk' + db.delete_table('catalogue_chunk') + + # Deleting model 'ChunkTag' + db.delete_table('catalogue_chunktag') + + # Deleting model 'ChunkChange' + db.delete_table('catalogue_chunkchange') + + # Removing M2M table for field tags on 'ChunkChange' + db.delete_table('catalogue_chunkchange_tags') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'catalogue.book': { + 'Meta': {'ordering': "['parent_number', 'title']", 'object_name': 'Book'}, + 'gallery': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_published': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['catalogue.Book']"}), + 'parent_number': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'catalogue.chunk': { + 'Meta': {'ordering': "['number']", 'unique_together': "[['book', 'number'], ['book', 'slug']]", 'object_name': 'Chunk'}, + 'book': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Book']"}), + 'comment': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'creator': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'created_documents'", 'null': 'True', 'to': "orm['auth.User']"}), + 'head': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['catalogue.ChunkChange']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'number': ('django.db.models.fields.IntegerField', [], {}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}), + 'stage': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ChunkTag']", 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}) + }, + 'catalogue.chunkchange': { + 'Meta': {'ordering': "('created_at',)", 'unique_together': "(['tree', 'revision'],)", 'object_name': 'ChunkChange'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'author_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'author_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'merge_parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'merge_children'", 'null': 'True', 'blank': 'True', 'to': "orm['catalogue.ChunkChange']"}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'children'", 'null': 'True', 'blank': 'True', 'to': "orm['catalogue.ChunkChange']"}), + 'patch': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'publishable': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'revision': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'change_set'", 'symmetrical': 'False', 'to': "orm['catalogue.ChunkTag']"}), + 'tree': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'change_set'", 'to': "orm['catalogue.Chunk']"}) + }, + 'catalogue.chunktag': { + 'Meta': {'ordering': "['ordering']", 'object_name': 'ChunkTag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'ordering': ('django.db.models.fields.IntegerField', [], {}), + 'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '64', 'unique': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['catalogue'] diff --git a/apps/catalogue/migrations/0002_auto__add_bookpublishrecord__add_chunkpublishrecord__del_field_book_la.py b/apps/catalogue/migrations/0002_auto__add_bookpublishrecord__add_chunkpublishrecord__del_field_book_la.py new file mode 100644 index 00000000..0c8efc08 --- /dev/null +++ b/apps/catalogue/migrations/0002_auto__add_bookpublishrecord__add_chunkpublishrecord__del_field_book_la.py @@ -0,0 +1,140 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'BookPublishRecord' + db.create_table('catalogue_bookpublishrecord', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('book', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['catalogue.Book'])), + ('timestamp', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + )) + db.send_create_signal('catalogue', ['BookPublishRecord']) + + # Adding model 'ChunkPublishRecord' + db.create_table('catalogue_chunkpublishrecord', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('book_record', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['catalogue.BookPublishRecord'])), + ('change', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['catalogue.ChunkChange'])), + )) + db.send_create_signal('catalogue', ['ChunkPublishRecord']) + + # Deleting field 'Book.last_published' + db.delete_column('catalogue_book', 'last_published') + + + def backwards(self, orm): + + # Deleting model 'BookPublishRecord' + db.delete_table('catalogue_bookpublishrecord') + + # Deleting model 'ChunkPublishRecord' + db.delete_table('catalogue_chunkpublishrecord') + + # Adding field 'Book.last_published' + db.add_column('catalogue_book', 'last_published', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True), keep_default=False) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'catalogue.book': { + 'Meta': {'ordering': "['parent_number', 'title']", 'object_name': 'Book'}, + 'gallery': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['catalogue.Book']"}), + 'parent_number': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + 'catalogue.bookpublishrecord': { + 'Meta': {'ordering': "['-timestamp']", 'object_name': 'BookPublishRecord'}, + 'book': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Book']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'catalogue.chunk': { + 'Meta': {'ordering': "['number']", 'unique_together': "[['book', 'number'], ['book', 'slug']]", 'object_name': 'Chunk'}, + 'book': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Book']"}), + 'comment': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'creator': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'created_documents'", 'null': 'True', 'to': "orm['auth.User']"}), + 'head': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['catalogue.ChunkChange']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'number': ('django.db.models.fields.IntegerField', [], {}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}), + 'stage': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ChunkTag']", 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}) + }, + 'catalogue.chunkchange': { + 'Meta': {'ordering': "('created_at',)", 'unique_together': "(['tree', 'revision'],)", 'object_name': 'ChunkChange'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'author_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'author_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'merge_parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'merge_children'", 'null': 'True', 'blank': 'True', 'to': "orm['catalogue.ChunkChange']"}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'children'", 'null': 'True', 'blank': 'True', 'to': "orm['catalogue.ChunkChange']"}), + 'patch': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'publishable': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'revision': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'change_set'", 'symmetrical': 'False', 'to': "orm['catalogue.ChunkTag']"}), + 'tree': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'change_set'", 'to': "orm['catalogue.Chunk']"}) + }, + 'catalogue.chunkpublishrecord': { + 'Meta': {'object_name': 'ChunkPublishRecord'}, + 'book_record': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.BookPublishRecord']"}), + 'change': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ChunkChange']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'catalogue.chunktag': { + 'Meta': {'ordering': "['ordering']", 'object_name': 'ChunkTag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'ordering': ('django.db.models.fields.IntegerField', [], {}), + 'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '64', 'unique': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['catalogue'] diff --git a/apps/catalogue/migrations/__init__.py b/apps/catalogue/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/catalogue/models.py b/apps/catalogue/models.py new file mode 100644 index 00000000..f9686547 --- /dev/null +++ b/apps/catalogue/models.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# +# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from dvcs import models as dvcs_models +from catalogue.xml_tools import compile_text + +import logging +logger = logging.getLogger("fnp.catalogue") + + +class Book(models.Model): + """ A document edited on the wiki """ + + title = models.CharField(_('title'), max_length=255, db_index=True) + slug = models.SlugField(_('slug'), max_length=128, unique=True, db_index=True) + gallery = models.CharField(_('scan gallery name'), max_length=255, blank=True) + + parent = models.ForeignKey('self', null=True, blank=True, verbose_name=_('parent'), related_name="children") + parent_number = models.IntegerField(_('parent number'), null=True, blank=True, db_index=True) + + class NoTextError(BaseException): + pass + + class Meta: + ordering = ['parent_number', 'title'] + verbose_name = _('book') + verbose_name_plural = _('books') + permissions = [('can_pubmark', 'Can mark for publishing')] + + def __unicode__(self): + return self.title + + def get_absolute_url(self): + return reverse("catalogue_book", args=[self.slug]) + + @classmethod + def create(cls, creator=None, text=u'', *args, **kwargs): + """ + >>> Book.create(slug='x', text='abc').materialize() + 'abc' + """ + instance = cls(*args, **kwargs) + instance.save() + instance[0].commit(author=creator, text=text) + return instance + + def __iter__(self): + return iter(self.chunk_set.all()) + + def __getitem__(self, chunk): + return self.chunk_set.all()[chunk] + + def __len__(self): + return self.chunk_set.count() + + def __nonzero__(self): + """ + Necessary so that __len__ isn't used for bool evaluation. + """ + return True + + def get_current_changes(self, publishable=True): + """ + Returns a list containing one Change for every Chunk in the Book. + Takes the most recent revision (publishable, if set). + Throws an error, if a proper revision is unavailable for a Chunk. + """ + if publishable: + changes = [chunk.publishable() for chunk in self] + else: + changes = [chunk.head for chunk in self] + if None in changes: + raise self.NoTextError('Some chunks have no available text.') + return changes + + def materialize(self, publishable=False, changes=None): + """ + Get full text of the document compiled from chunks. + Takes the current versions of all texts + or versions most recently tagged for publishing, + or a specified iterable changes. + """ + if changes is None: + changes = self.get_current_changes(publishable) + return compile_text(change.materialize() for change in changes) + + def publishable(self): + if not self.chunk_set.exists(): + return False + for chunk in self: + if not chunk.publishable(): + return False + return True + + def publish(self, user): + """ + Publishes a book on behalf of a (local) user. + """ + from apiclient import api_call + + changes = self.get_current_changes(publishable=True) + book_xml = book.materialize(changes=changes) + #api_call(user, "books", {"book_xml": book_xml}) + # record the publish + br = BookPublishRecord.objects.create(book=self, user=user) + for c in changes: + ChunkPublishRecord.objects.create(book_record=br, change=c) + + def make_chunk_slug(self, proposed): + """ + Finds a chunk slug not yet used in the book. + """ + slugs = set(c.slug for c in self) + i = 1 + new_slug = proposed + while new_slug in slugs: + new_slug = "%s-%d" % (proposed, i) + i += 1 + return new_slug + + def append(self, other): + number = self[len(self) - 1].number + 1 + single = len(other) == 1 + for chunk in other: + # move chunk to new book + chunk.book = self + chunk.number = number + + # try some title guessing + if other.title.startswith(self.title): + other_title_part = other.title[len(self.title):].lstrip(' /') + else: + other_title_part = other.title + + if single: + # special treatment for appending one-parters: + # just use the guessed title and original book slug + chunk.comment = other_title_part + if other.slug.startswith(self.slug): + chunk_slug = other.slug[len(self.slug):].lstrip('-_') + else: + chunk_slug = other.slug + chunk.slug = self.make_chunk_slug(chunk_slug) + else: + chunk.comment = "%s, %s" % (other_title_part, chunk.comment) + chunk.slug = self.make_chunk_slug(chunk.slug) + chunk.save() + number += 1 + other.delete() + + @staticmethod + def listener_create(sender, instance, created, **kwargs): + if created: + instance.chunk_set.create(number=1, slug='1') + +models.signals.post_save.connect(Book.listener_create, sender=Book) + + +class Chunk(dvcs_models.Document): + """ An editable chunk of text. Every Book text is divided into chunks. """ + + book = models.ForeignKey(Book, editable=False) + number = models.IntegerField() + slug = models.SlugField() + comment = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = [['book', 'number'], ['book', 'slug']] + ordering = ['number'] + + def __unicode__(self): + return "%d-%d: %s" % (self.book_id, self.number, self.comment) + + def get_absolute_url(self): + return reverse("wiki_editor", args=[self.book.slug, self.slug]) + + @classmethod + def get(cls, slug, chunk=None): + if chunk is None: + return cls.objects.get(book__slug=slug, number=1) + else: + return cls.objects.get(book__slug=slug, slug=chunk) + + def pretty_name(self, book_length=None): + title = self.book.title + if self.comment: + title += ", %s" % self.comment + if book_length > 1: + title += " (%d/%d)" % (self.number, book_length) + return title + + def split(self, slug, comment='', creator=None): + """ Create an empty chunk after this one """ + self.book.chunk_set.filter(number__gt=self.number).update( + number=models.F('number')+1) + new_chunk = self.book.chunk_set.create(number=self.number+1, + creator=creator, slug=slug, comment=comment) + return new_chunk + + @staticmethod + def listener_saved(sender, instance, created, **kwargs): + if instance.book: + # save book so that its _list_html is reset + instance.book.save() + +models.signals.post_save.connect(Chunk.listener_saved, sender=Chunk) + + +class BookPublishRecord(models.Model): + """ + A record left after publishing a Book. + """ + + book = models.ForeignKey(Book) + timestamp = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User) + + class Meta: + ordering = ['-timestamp'] + + +class ChunkPublishRecord(models.Model): + """ + BookPublishRecord details for each Chunk. + """ + + book_record = models.ForeignKey(BookPublishRecord) + change = models.ForeignKey(Chunk.change_model) diff --git a/apps/catalogue/templates/catalogue/base.html b/apps/catalogue/templates/catalogue/base.html new file mode 100644 index 00000000..4ba8a0ea --- /dev/null +++ b/apps/catalogue/templates/catalogue/base.html @@ -0,0 +1,49 @@ +{% load compressed i18n %} +{% load catalogue %} + + + + + {% compressed_css 'listing' %} + + {% block title %}{% trans "Platforma Redakcyjna" %}{% endblock title %} + + + +
+ + + + + +
+ {% main_tabs %} +
+ + + {% include "registration/head_login.html" %} + + +
+
+ +
+ +{% block content %} +
+ {% block leftcolumn %} + {% endblock leftcolumn %} +
+
+ {% block rightcolumn %} + {% endblock rightcolumn %} +
+{% endblock content %} + +
+ +{% compressed_js 'listing' %} +{% block extrabody %} +{% endblock %} + + diff --git a/apps/catalogue/templates/catalogue/book_append_to.html b/apps/catalogue/templates/catalogue/book_append_to.html new file mode 100755 index 00000000..76a59621 --- /dev/null +++ b/apps/catalogue/templates/catalogue/book_append_to.html @@ -0,0 +1,14 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {% csrf_token %} + {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/book_detail.html b/apps/catalogue/templates/catalogue/book_detail.html new file mode 100755 index 00000000..aac190ad --- /dev/null +++ b/apps/catalogue/templates/catalogue/book_detail.html @@ -0,0 +1,106 @@ +{% extends "catalogue/base.html" %} +{% load comments i18n %} + +{% block leftcolumn %} + +{% trans "edit" %} +

{{ book.title }}

+ + + {% for c in chunks %} + + + + + + + + + {% endfor %} + {% if need_fixing %} + + {% endif %} +
{{ c.chunk.comment }}{% for fix in c.fix %} + + {% ifequal fix "wl" %}</>{% endifequal %} + + {% ifequal fix "bad-master" %}master{% endifequal %} + + {% ifequal fix "trim-begin" %}{% endifequal %} + + {% ifequal fix "trim-end" %}{% endifequal %} + + {% endfor %} + + {% ifequal c.grade "plain" %} + {% trans "unstructured text" %} + {% endifequal %} + + {% ifequal c.grade "xml" %} + {% trans "unknown XML" %} + {% endifequal %} + + {% ifequal c.grade "wl-broken" %} + {% trans "broken document" %} + {% endifequal %} + + [{% trans "edit" %}]{% if c.chunk.publishable %}P{% endif %}{% if c.chunk.user.is_authenticated %} + {{ c.chunk.user }} + {% endif %}[+]
+
+ {% csrf_token %} + {% if choose_master %} + {{ form.master }} + {% endif %} + +
+
+ +

{% trans "Append to other book" %}

+ +

{% trans "Last published" %}: {{ book.last_published }}

+ +{% if book.publishable %} +

+ {% trans "Full XML" %}
+ {% trans "HTML version" %}
+ {% trans "TXT version" %}
+ {% comment %} + {% trans "EPUB version" %}
+ {% trans "PDF version" %}
+ {% endcomment %} +

+ + +
{% csrf_token %} + + + +

+{% else %} + {% trans "This book cannot be published yet" %} +{% endif %} + +{% endblock leftcolumn %} + +{% block rightcolumn %} +{% render_comment_list for book %} +{% with book.get_absolute_url as next %} + {% render_comment_form for book %} +{% endwith %} + +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/book_edit.html b/apps/catalogue/templates/catalogue/book_edit.html new file mode 100755 index 00000000..3fffa963 --- /dev/null +++ b/apps/catalogue/templates/catalogue/book_edit.html @@ -0,0 +1,14 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {% csrf_token %} + {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/chunk_add.html b/apps/catalogue/templates/catalogue/chunk_add.html new file mode 100755 index 00000000..800a7e45 --- /dev/null +++ b/apps/catalogue/templates/catalogue/chunk_add.html @@ -0,0 +1,14 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {% csrf_token %} + {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/chunk_edit.html b/apps/catalogue/templates/catalogue/chunk_edit.html new file mode 100755 index 00000000..3fffa963 --- /dev/null +++ b/apps/catalogue/templates/catalogue/chunk_edit.html @@ -0,0 +1,14 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {% csrf_token %} + {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/document_create_missing.html b/apps/catalogue/templates/catalogue/document_create_missing.html new file mode 100644 index 00000000..dcb61752 --- /dev/null +++ b/apps/catalogue/templates/catalogue/document_create_missing.html @@ -0,0 +1,14 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {% csrf_token %} + {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/document_list.html b/apps/catalogue/templates/catalogue/document_list.html new file mode 100644 index 00000000..3473b596 --- /dev/null +++ b/apps/catalogue/templates/catalogue/document_list.html @@ -0,0 +1,128 @@ +{% extends "catalogue/base.html" %} + +{% load i18n %} +{% load pagination_tags %} +{% load set_get_parameter catalogue %} + +{% block extrabody %} +{{ block.super }} + +{% endblock %} + +{% block leftcolumn %} + +
+ + + + + + + + {% if not viewed_user %} + + {% endif %} + + + + + {% autopaginate books 100 %} + {% if not books %} + + {% endif %} + {% for item in books %} + {% with item.book as book %} + + {% ifequal item.book_length 1 %} + {% with item.chunks.0 as chunk %} + + + + + + + + {% endwith %} + {% else %} + + + + + + {% for chunk in item.chunks %} + + + + + + {% if not viewed_user %} + + {% endif %} + + {% endfor %} + {% endifequal %} + {% endwith %} + {% endfor %} + + +
{% trans "No books found." %}
[B][c] + {{ book.title }}{% if chunk.stage %} + ({{ chunk.stage }}) + {% else %}– + {% endif %}{% if chunk.user %}{{ chunk.user.first_name }} {{ chunk.user.last_name }}{% endif %}
[B]{{ book.title }}
[c] + {{ chunk.number }}. + {{ chunk.comment }}{% if chunk.stage %} + {{ chunk.stage }} + {% else %} + – + {% endif %}{% if chunk.user %} + + {{ chunk.user.first_name }} {{ chunk.user.last_name }} + {% else %} + + {% endif %}
{% paginate %}
+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +
+

{% trans "Your last edited documents" %}

+
    + {% for slugs, item in last_books %} +
  1. {{ item.title }}
    ({{ item.time|date:"H:i:s, d/m/Y" }})
  2. + {% endfor %} +
+
+ + {% if viewed_user %} +

{% trans "Recent activity for" %} {{ viewed_user }}

+ {% wall viewed_user %} + {% else %} +

{% trans "Recent activity" %}

+ {% wall %} + {% endif %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/document_upload.html b/apps/catalogue/templates/catalogue/document_upload.html new file mode 100644 index 00000000..87e93e0a --- /dev/null +++ b/apps/catalogue/templates/catalogue/document_upload.html @@ -0,0 +1,69 @@ +{% extends "catalogue/base.html" %} +{% load i18n %} + + +{% block leftcolumn %} + + +

{% trans "Bulk documents upload" %}

+ +

+{% trans "Please submit a ZIP with UTF-8 encoded XML files. Files not ending with .xml will be ignored." %} +

+ +
+{% csrf_token %} +{{ form.as_p }} +

+
+ +
+ +{% if error_list %} + +

{% trans "There have been some errors. No files have been added to the repository." %} +

{% trans "Offending files" %}

+
    + {% for filename, title, error in error_list %} +
  • {{ title }} ({{ filename }}): {{ error }}
  • + {% endfor %} +
+ + {% if ok_list %} +

{% trans "Correct files" %}

+
    + {% for filename, slug, title in ok_list %} +
  • {{ title }} ({{ filename }})
  • + {% endfor %} +
+ {% endif %} + +{% else %} + + {% if ok_list %} +

{% trans "Files have been successfully uploaded to the repository." %}

+

{% trans "Uploaded files" %}

+
    + {% for filename, slug, title in ok_list %} +
  • {{ title }} ({{ filename }})
  • + {% endfor %} +
+ {% endif %} +{% endif %} + +{% if skipped_list %} +

{% trans "Skipped files" %}

+

{% trans "Files skipped due to no .xml extension" %}

+
    + {% for filename in skipped_list %} +
  • {{ filename }}
  • + {% endfor %} +
+{% endif %} + + +{% endblock leftcolumn %} + + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/catalogue/templates/catalogue/main_tabs.html b/apps/catalogue/templates/catalogue/main_tabs.html new file mode 100755 index 00000000..82321cc4 --- /dev/null +++ b/apps/catalogue/templates/catalogue/main_tabs.html @@ -0,0 +1,3 @@ +{% for tab in tabs %} + {{ tab.caption }} +{% endfor %} diff --git a/apps/catalogue/templates/catalogue/user_list.html b/apps/catalogue/templates/catalogue/user_list.html new file mode 100755 index 00000000..9e1e83e5 --- /dev/null +++ b/apps/catalogue/templates/catalogue/user_list.html @@ -0,0 +1,18 @@ +{% extends "catalogue/base.html" %} + +{% load i18n %} + +{% block leftcolumn %} + +

{% trans "Users" %}

+ + + +{% endblock leftcolumn %} diff --git a/apps/catalogue/templates/catalogue/wall.html b/apps/catalogue/templates/catalogue/wall.html new file mode 100755 index 00000000..0e4597e5 --- /dev/null +++ b/apps/catalogue/templates/catalogue/wall.html @@ -0,0 +1,32 @@ +{% load i18n %} +{% load gravatar %} + +
    +{% for item in wall %} +
  • +
    + {% if item.get_email %} + {% gravatar_img_for_email item.get_email 32 %} +
    + {% endif %} + + +
    + + {{ item.timestamp }} +
    + {% if item.user %} + + {{ item.user.first_name }} {{ item.user.last_name }} + <{{ item.user.email }}> + {% else %} + {{ item.user_name }} + {% if item.get_email %} + <{{ item.get_email }}> + {% endif %} + {% endif %} +
    {{ item.title }} +
    {{ item.summary }} +
  • +{% endfor %} +
diff --git a/apps/catalogue/templatetags/__init__.py b/apps/catalogue/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/catalogue/templatetags/catalogue.py b/apps/catalogue/templatetags/catalogue.py new file mode 100644 index 00000000..850a2e80 --- /dev/null +++ b/apps/catalogue/templatetags/catalogue.py @@ -0,0 +1,151 @@ +from __future__ import absolute_import + +from django.db.models import Count, Q +from django.core.urlresolvers import reverse +from django.contrib.comments.models import Comment +from django.template.defaultfilters import stringfilter +from django import template +from django.utils.translation import ugettext as _ + +from catalogue.models import Book, Chunk, BookPublishRecord + +register = template.Library() + + +class Tab(object): + slug = None + caption = None + url = None + + def __init__(self, slug, caption, url): + self.slug = slug + self.caption = caption + self.url = url + + +@register.inclusion_tag("catalogue/main_tabs.html", takes_context=True) +def main_tabs(context): + active = getattr(context['request'], 'catalogue_active_tab', None) + + tabs = [] + user = context['user'] + if user.is_authenticated(): + tabs.append(Tab('my', _('My page'), reverse("catalogue_user"))) + + tabs.append(Tab('all', _('All'), reverse("catalogue_document_list"))) + tabs.append(Tab('users', _('Users'), reverse("catalogue_users"))) + tabs.append(Tab('create', _('Add'), reverse("catalogue_create_missing"))) + tabs.append(Tab('upload', _('Upload'), reverse("catalogue_upload"))) + + if user.is_staff: + tabs.append(Tab('admin', _('Admin'), reverse("admin:index"))) + + return {"tabs": tabs, "active_tab": active} + + +class WallItem(object): + title = '' + summary = '' + url = '' + timestamp = '' + user = None + email = '' + + def __init__(self, tag): + self.tag = tag + + def get_email(self): + if self.user: + return self.user.email + else: + return self.email + + +def changes_wall(user, max_len): + qs = Chunk.change_model.objects.filter(revision__gt=-1).order_by('-created_at') + qs = qs.defer('patch') + qs = qs.select_related('author', 'tree', 'tree__book__title') + if user: + qs = qs.filter(Q(author=user) | Q(tree__user=user)) + qs = qs[:max_len] + for item in qs: + tag = 'stage' if item.tags.count() else 'change' + chunk = item.tree + w = WallItem(tag) + w.title = chunk.pretty_name() + w.summary = item.description + w.url = reverse('wiki_editor', + args=[chunk.book.slug, chunk.slug]) + '?diff=%d' % item.revision + w.timestamp = item.created_at + w.user = item.author + w.email = item.author_email + yield w + + +# TODO: marked for publishing + + +def published_wall(user, max_len): + qs = BookPublishRecord.objects.select_related('book__title') + if user: + # TODO: published my book + qs = qs.filter(Q(user=user)) + qs = qs[:max_len] + for item in qs: + w = WallItem('publish') + w.title = item.book.title + #w.summary = + w.url = chunk.book.get_absolute_url() + yield w + + +def comments_wall(user, max_len): + qs = Comment.objects.filter(is_public=True).select_related().order_by('-submit_date') + if user: + # TODO: comments concerning my books + qs = qs.filter(Q(user=user)) + qs = qs[:max_len] + for item in qs: + w = WallItem('comment') + w.title = item.content_object + w.summary = item.comment + w.url = item.content_object.get_absolute_url() + w.timestamp = item.submit_date + w.user = item.user + w.email = item.user_email + yield w + + +def big_wall(max_len, *args): + """ + Takes some WallItem iterators and zips them into one big wall. + Input iterators must already be sorted by timestamp. + """ + subwalls = [] + for w in args: + try: + subwalls.append([next(w), w]) + except StopIteration: + pass + + while max_len and subwalls: + i, next_item = max(enumerate(subwalls), key=lambda x: x[1][0].timestamp) + yield next_item[0] + max_len -= 1 + try: + next_item[0] = next(next_item[1]) + except StopIteration: + del subwalls[i] + + +@register.inclusion_tag("catalogue/wall.html", takes_context=True) +def wall(context, user=None, max_len=10): + print user + return { + "request": context['request'], + "STATIC_URL": context['STATIC_URL'], + "wall": big_wall(max_len, + changes_wall(user, max_len), + published_wall(user, max_len), + comments_wall(user, max_len), + )} diff --git a/apps/catalogue/templatetags/set_get_parameter.py b/apps/catalogue/templatetags/set_get_parameter.py new file mode 100755 index 00000000..b3d44d73 --- /dev/null +++ b/apps/catalogue/templatetags/set_get_parameter.py @@ -0,0 +1,46 @@ +from re import split + +from django import template + +register = template.Library() + + +""" +In template: + {% set_get_paramater param1='const_value',param2=,param3=variable %} +results with changes to query string: + param1 is set to `const_value' string + param2 is unset, if exists, + param3 is set to the value of variable in context + +Using 'django.core.context_processors.request' is required. + +""" + + +class SetGetParameter(template.Node): + def __init__(self, values): + self.values = values + + def render(self, context): + request = template.Variable('request').resolve(context) + params = request.GET.copy() + for key, value in self.values.items(): + if value == '': + if key in params: + del(params[key]) + else: + params[key] = template.Variable(value).resolve(context) + return '?%s' % params.urlencode() + + +@register.tag +def set_get_parameter(parser, token): + parts = split(r'\s+', token.contents, 2) + + values = {} + for pair in parts[1].split(','): + s = pair.split('=') + values[s[0]] = s[1] + + return SetGetParameter(values) diff --git a/apps/catalogue/tests.py b/apps/catalogue/tests.py new file mode 100644 index 00000000..65777379 --- /dev/null +++ b/apps/catalogue/tests.py @@ -0,0 +1,19 @@ +from nose.tools import * +import wiki.models as models +import shutil +import tempfile + + +class TestStorageBase: + def setUp(self): + self.dirpath = tempfile.mkdtemp(prefix='nosetest_') + + def tearDown(self): + shutil.rmtree(self.dirpath) + + +class TestDocumentStorage(TestStorageBase): + + def test_storage_empty(self): + storage = models.DocumentStorage(self.dirpath) + eq_(storage.all(), []) diff --git a/apps/catalogue/urls.py b/apps/catalogue/urls.py new file mode 100644 index 00000000..e9e5e893 --- /dev/null +++ b/apps/catalogue/urls.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 +from django.conf.urls.defaults import * +from django.views.generic.simple import redirect_to + + +urlpatterns = patterns('catalogue.views', + url(r'^$', redirect_to, {'url': 'catalogue/'}), + + url(r'^catalogue/$', 'document_list', name='catalogue_document_list'), + url(r'^user/$', 'my', name='catalogue_user'), + url(r'^user/(?P[^/]+)/$', 'user', name='catalogue_user'), + url(r'^users/$', 'users', name='catalogue_users'), + + url(r'^upload/$', + 'upload', name='catalogue_upload'), + + url(r'^create/(?P[^/]*)/', + 'create_missing', name='catalogue_create_missing'), + url(r'^create/', + 'create_missing', name='catalogue_create_missing'), + + url(r'^book/(?P[^/]+)/publish$', 'publish', name="catalogue_publish"), + #url(r'^(?P[^/]+)/publish/(?P\d+)$', 'publish', name="catalogue_publish"), + + url(r'^book/(?P[^/]+)/$', 'book', name="catalogue_book"), + url(r'^book/(?P[^/]+)/xml$', 'book_xml', name="catalogue_book_xml"), + url(r'^book/(?P[^/]+)/txt$', 'book_txt', name="catalogue_book_txt"), + url(r'^book/(?P[^/]+)/html$', 'book_html', name="catalogue_book_html"), + #url(r'^book/(?P[^/]+)/epub$', 'book_epub', name="catalogue_book_epub"), + #url(r'^book/(?P[^/]+)/pdf$', 'book_pdf', name="catalogue_book_pdf"), + url(r'^chunk_add/(?P[^/]+)/(?P[^/]+)/$', + 'chunk_add', name="catalogue_chunk_add"), + url(r'^chunk_edit/(?P[^/]+)/(?P[^/]+)/$', + 'chunk_edit', name="catalogue_chunk_edit"), + url(r'^book_append/(?P[^/]+)/$', + 'book_append', name="catalogue_book_append"), + url(r'^book_edit/(?P[^/]+)/$', + 'book_edit', name="catalogue_book_edit"), + +) diff --git a/apps/catalogue/views.py b/apps/catalogue/views.py new file mode 100644 index 00000000..3c58f3f0 --- /dev/null +++ b/apps/catalogue/views.py @@ -0,0 +1,422 @@ +from datetime import datetime +import logging +import os +from StringIO import StringIO + +from django.contrib import auth +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse +from django.db.models import Count +from django import http +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils.http import urlquote_plus +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.http import require_POST +from django.views.generic.simple import direct_to_template + +import librarian.html +import librarian.text + +from catalogue import forms +from catalogue import helpers +from catalogue.helpers import active_tab +from catalogue.models import Book, Chunk, BookPublishRecord, ChunkPublishRecord +from catalogue import xml_tools + +# +# Quick hack around caching problems, TODO: use ETags +# +from django.views.decorators.cache import never_cache + +logger = logging.getLogger("fnp.catalogue") + + +def slug_filter(qs, value, filter_field, model, model_field='slug', unset=''): + if value == unset: + return qs.filter(**{filter_field: None}) + if value is None: + return qs + try: + obj = model._default_manager.get(**{model_field: value}) + except model.DoesNotExist: + return qs.none() + else: + return qs.filter(**{filter_field: obj}) + + +@active_tab('all') +@never_cache +def document_list(request, filters=None): + chunks = Chunk.objects.order_by('book__title', 'book', 'number') + + chunks = slug_filter(chunks, request.GET.get('user', None), 'user', User, 'username') + chunks = slug_filter(chunks, request.GET.get('stage', None), 'stage', Chunk.tag_model, 'slug') + + chunks_list = helpers.ChunksList(chunks) + + return direct_to_template(request, 'catalogue/document_list.html', extra_context={ + 'books': chunks_list, + 'last_books': sorted(request.session.get("wiki_last_books", {}).items(), + key=lambda x: x[1]['time'], reverse=True), + 'stages': Chunk.tag_model.objects.all(), + 'users': User.objects.annotate(count=Count('chunk')).order_by('-count', 'last_name', 'first_name'), + }) + + +@never_cache +def user(request, username=None): + if username is None: + if request.user.is_authenticated(): + user = request.user + else: + raise Http404 + else: + user = get_object_or_404(User, username=username) + + chunks_list = helpers.ChunksList(Chunk.objects.filter( + user=user).order_by('book__title', 'book', 'number')) + + return direct_to_template(request, 'catalogue/document_list.html', extra_context={ + 'books': chunks_list, + 'last_books': sorted(request.session.get("wiki_last_books", {}).items(), + key=lambda x: x[1]['time'], reverse=True), + 'viewed_user': user, + 'stages': Chunk.tag_model.objects.all(), + }) +my = login_required(active_tab('my')(user)) + + +@active_tab('users') +def users(request): + return direct_to_template(request, 'catalogue/user_list.html', extra_context={ + 'users': User.objects.all().annotate(count=Count('chunk')).order_by( + '-count', 'last_name', 'first_name'), + }) + + +@never_cache +def logout_then_redirect(request): + auth.logout(request) + return http.HttpResponseRedirect(urlquote_plus(request.GET.get('next', '/'), safe='/?=')) + + +@active_tab('create') +def create_missing(request, slug=None): + if slug is None: + slug = '' + slug = slug.replace(' ', '-') + + if request.method == "POST": + form = forms.DocumentCreateForm(request.POST, request.FILES) + if form.is_valid(): + + if request.user.is_authenticated(): + creator = request.user + else: + creator = None + book = Book.create(creator=creator, + slug=form.cleaned_data['slug'], + title=form.cleaned_data['title'], + text=form.cleaned_data['text'], + ) + + return http.HttpResponseRedirect(reverse("wiki_editor", args=[book.slug])) + else: + form = forms.DocumentCreateForm(initial={ + "slug": slug, + "title": slug.replace('-', ' ').title(), + }) + + return direct_to_template(request, "catalogue/document_create_missing.html", extra_context={ + "slug": slug, + "form": form, + }) + + +@active_tab('upload') +def upload(request): + if request.method == "POST": + form = forms.DocumentsUploadForm(request.POST, request.FILES) + if form.is_valid(): + import slughifi + + if request.user.is_authenticated(): + creator = request.user + else: + creator = None + + zip = form.cleaned_data['zip'] + skipped_list = [] + ok_list = [] + error_list = [] + slugs = {} + existing = [book.slug for book in Book.objects.all()] + for filename in zip.namelist(): + if filename[-1] == '/': + continue + title = os.path.basename(filename)[:-4] + slug = slughifi(title) + if not (slug and filename.endswith('.xml')): + skipped_list.append(filename) + elif slug in slugs: + error_list.append((filename, slug, _('Slug already used for %s' % slugs[slug]))) + elif slug in existing: + error_list.append((filename, slug, _('Slug already used in repository.'))) + else: + try: + zip.read(filename).decode('utf-8') # test read + ok_list.append((filename, slug, title)) + except UnicodeDecodeError: + error_list.append((filename, title, _('File should be UTF-8 encoded.'))) + slugs[slug] = filename + + if not error_list: + for filename, slug, title in ok_list: + Book.create(creator=creator, + slug=slug, + title=title, + text=zip.read(filename).decode('utf-8'), + ) + + return direct_to_template(request, "catalogue/document_upload.html", extra_context={ + "form": form, + "ok_list": ok_list, + "skipped_list": skipped_list, + "error_list": error_list, + }) + else: + form = forms.DocumentsUploadForm() + + return direct_to_template(request, "catalogue/document_upload.html", extra_context={ + "form": form, + }) + + +@never_cache +def book_xml(request, slug): + xml = get_object_or_404(Book, slug=slug).materialize() + + response = http.HttpResponse(xml, content_type='application/xml', mimetype='application/wl+xml') + response['Content-Disposition'] = 'attachment; filename=%s.xml' % slug + return response + + +@never_cache +def book_txt(request, slug): + xml = get_object_or_404(Book, slug=slug).materialize() + output = StringIO() + # errors? + librarian.text.transform(StringIO(xml), output) + text = output.getvalue() + response = http.HttpResponse(text, content_type='text/plain', mimetype='text/plain') + response['Content-Disposition'] = 'attachment; filename=%s.txt' % slug + return response + + +@never_cache +def book_html(request, slug): + xml = get_object_or_404(Book, slug=slug).materialize() + output = StringIO() + # errors? + librarian.html.transform(StringIO(xml), output, parse_dublincore=False, + flags=['full-page']) + html = output.getvalue() + response = http.HttpResponse(html, content_type='text/html', mimetype='text/html') + return response + + +@never_cache +def revision(request, slug, chunk=None): + try: + doc = Chunk.get(slug, chunk) + except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist): + raise Http404 + return http.HttpResponse(str(doc.revision())) + + +def book(request, slug): + book = get_object_or_404(Book, slug=slug) + + # TODO: most of this should go somewhere else + + # do we need some automation? + first_master = None + chunks = [] + need_fixing = False + choose_master = False + + length = book.chunk_set.count() + for i, chunk in enumerate(book): + chunk_dict = { + "chunk": chunk, + "fix": [], + "grade": "" + } + graded = xml_tools.GradedText(chunk.materialize()) + if graded.is_wl(): + master = graded.master() + if first_master is None: + first_master = master + elif master != first_master: + chunk_dict['fix'].append('bad-master') + + if i > 0 and not graded.has_trim_begin(): + chunk_dict['fix'].append('trim-begin') + if i < length - 1 and not graded.has_trim_end(): + chunk_dict['fix'].append('trim-end') + + if chunk_dict['fix']: + chunk_dict['grade'] = 'wl-fix' + else: + chunk_dict['grade'] = 'wl' + + elif graded.is_broken_wl(): + chunk_dict['grade'] = 'wl-broken' + elif graded.is_xml(): + chunk_dict['grade'] = 'xml' + else: + chunk_dict['grade'] = 'plain' + chunk_dict['fix'].append('wl') + choose_master = True + + if chunk_dict['fix']: + need_fixing = True + chunks.append(chunk_dict) + + if first_master or not need_fixing: + choose_master = False + + if request.method == "POST": + form = forms.ChooseMasterForm(request.POST) + if not choose_master or form.is_valid(): + if choose_master: + first_master = form.cleaned_data['master'] + + # do the actual fixing + for c in chunks: + if not c['fix']: + continue + + text = c['chunk'].materialize() + for fix in c['fix']: + if fix == 'bad-master': + text = xml_tools.change_master(text, first_master) + elif fix == 'trim-begin': + text = xml_tools.add_trim_begin(text) + elif fix == 'trim-end': + text = xml_tools.add_trim_end(text) + elif fix == 'wl': + text = xml_tools.basic_structure(text, first_master) + author = request.user if request.user.is_authenticated() else None + description = "auto-fix: " + ", ".join(c['fix']) + c['chunk'].commit(text=text, author=author, + description=description) + + return http.HttpResponseRedirect(book.get_absolute_url()) + elif choose_master: + form = forms.ChooseMasterForm() + else: + form = None + + return direct_to_template(request, "catalogue/book_detail.html", extra_context={ + "book": book, + "chunks": chunks, + "need_fixing": need_fixing, + "choose_master": choose_master, + "first_master": first_master, + "form": form, + }) + + +def chunk_add(request, slug, chunk): + try: + doc = Chunk.get(slug, chunk) + except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist): + raise Http404 + + if request.method == "POST": + form = forms.ChunkAddForm(request.POST, instance=doc) + if form.is_valid(): + if request.user.is_authenticated(): + creator = request.user + else: + creator = None + doc.split(creator=creator, + slug=form.cleaned_data['slug'], + comment=form.cleaned_data['comment'], + ) + + return http.HttpResponseRedirect(doc.book.get_absolute_url()) + else: + form = forms.ChunkAddForm(initial={ + "slug": str(doc.number + 1), + "comment": "cz. %d" % (doc.number + 1, ), + }) + + return direct_to_template(request, "catalogue/chunk_add.html", extra_context={ + "chunk": doc, + "form": form, + }) + + +def chunk_edit(request, slug, chunk): + try: + doc = Chunk.get(slug, chunk) + except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist): + raise Http404 + if request.method == "POST": + form = forms.ChunkForm(request.POST, instance=doc) + if form.is_valid(): + form.save() + return http.HttpResponseRedirect(doc.book.get_absolute_url()) + else: + form = forms.ChunkForm(instance=doc) + return direct_to_template(request, "catalogue/chunk_edit.html", extra_context={ + "chunk": doc, + "form": form, + }) + + +def book_append(request, slug): + book = get_object_or_404(Book, slug=slug) + if request.method == "POST": + form = forms.BookAppendForm(request.POST) + if form.is_valid(): + append_to = form.cleaned_data['append_to'] + append_to.append(book) + return http.HttpResponseRedirect(append_to.get_absolute_url()) + else: + form = forms.BookAppendForm() + return direct_to_template(request, "catalogue/book_append_to.html", extra_context={ + "book": book, + "form": form, + }) + + +def book_edit(request, slug): + book = get_object_or_404(Book, slug=slug) + if request.method == "POST": + form = forms.BookForm(request.POST, instance=book) + if form.is_valid(): + form.save() + return http.HttpResponseRedirect(book.get_absolute_url()) + else: + form = forms.BookForm(instance=book) + return direct_to_template(request, "catalogue/book_edit.html", extra_context={ + "book": book, + "form": form, + }) + + +@require_POST +@login_required +def publish(request, slug): + book = get_object_or_404(Book, slug=slug) + try: + book.publish(request.user) + except BaseException, e: + return http.HttpResponse(e) + else: + return http.HttpResponseRedirect(book.get_absolute_url()) diff --git a/apps/catalogue/xml_tools.py b/apps/catalogue/xml_tools.py new file mode 100755 index 00000000..928e57be --- /dev/null +++ b/apps/catalogue/xml_tools.py @@ -0,0 +1,204 @@ +from functools import wraps +import re + +from lxml import etree +from catalogue.constants import TRIM_BEGIN, TRIM_END, MASTERS + +RE_TRIM_BEGIN = re.compile("^$" % TRIM_BEGIN, re.M) +RE_TRIM_END = re.compile("^$" % TRIM_END, re.M) + + +class ParseError(BaseException): + pass + + +def obj_memoized(f): + """ + A decorator that caches return value of object methods. + The cache is kept with the object, in a _obj_memoized property. + """ + @wraps(f) + def wrapper(self, *args, **kwargs): + if not hasattr(self, '_obj_memoized'): + self._obj_memoized = {} + key = (f.__name__,) + args + tuple(sorted(kwargs.iteritems())) + try: + return self._obj_memoized[key] + except TypeError: + return f(self, *args, **kwargs) + except KeyError: + self._obj_memoized[key] = f(self, *args, **kwargs) + return self._obj_memoized[key] + return wrapper + + +class GradedText(object): + _edoc = None + + ROOT = 'utwor' + RDF = '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF' + + def __init__(self, text): + self._text = text + + @obj_memoized + def is_xml(self): + """ + Determines if it's a well-formed XML. + + >>> GradedText("").is_xml() + True + >>> GradedText("").is_xml() + False + """ + try: + self._edoc = etree.fromstring(self._text) + except etree.XMLSyntaxError: + return False + return True + + @obj_memoized + def is_wl(self): + """ + Determines if it's an XML with a and a master tag. + + >>> GradedText("").is_wl() + True + >>> GradedText("").is_wl() + False + """ + if self.is_xml(): + e = self._edoc + # FIXME: there could be comments + ret = e.tag == self.ROOT and ( + len(e) == 1 and e[0].tag in MASTERS or + len(e) == 2 and e[0].tag == self.RDF + and e[1].tag in MASTERS) + if ret: + self._master = e[-1].tag + del self._edoc + return ret + else: + return False + + @obj_memoized + def is_broken_wl(self): + """ + Determines if it at least looks like broken WL file + and not just some untagged text. + + >>> GradedText("<").is_broken_wl() + True + >>> GradedText("some text").is_broken_wl() + False + """ + if self.is_wl(): + return True + text = self._text.strip() + return text.startswith('') and text.endswith('') + + def master(self): + """ + Gets the master tag. + + >>> GradedText("").master() + 'powiesc' + """ + assert self.is_wl() + return self._master + + @obj_memoized + def has_trim_begin(self): + return RE_TRIM_BEGIN.search(self._text) + + @obj_memoized + def has_trim_end(self): + return RE_TRIM_END.search(self._text) + + +def _trim(text, trim_begin=True, trim_end=True): + """ + Cut off everything before RE_TRIM_BEGIN and after RE_TRIM_END, so + that eg. one big XML file can be compiled from many small XML files. + """ + if trim_begin: + text = RE_TRIM_BEGIN.split(text, maxsplit=1)[-1] + if trim_end: + text = RE_TRIM_END.split(text, maxsplit=1)[0] + return text + + +def compile_text(parts): + """ + Compiles full text from an iterable of parts, + trimming where applicable. + """ + texts = [] + trim_begin = False + text = '' + for next_text in parts: + if not next_text: + continue + # trim the end, because there's more non-empty text + # don't trim beginning, if `text' is the first non-empty part + texts.append(_trim(text, trim_begin=trim_begin)) + trim_begin = True + text = next_text + # don't trim the end, because there's no more text coming after `text' + # only trim beginning if it's not still the first non-empty + texts.append(_trim(text, trim_begin=trim_begin, trim_end=False)) + return "".join(texts) + + +def change_master(text, master): + """ + Changes the master tag in a WL document. + """ + e = etree.fromstring(text) + e[-1].tag = master + return etree.tostring(e, encoding="utf-8") + + +def basic_structure(text, master): + e = etree.fromstring(''' + + + +''' % (TRIM_BEGIN, TRIM_END)) + e[0].tag = master + e[0][0].tail = "\n"*3 + text + "\n"*3 + return etree.tostring(e, encoding="utf-8") + + +def add_trim_begin(text): + trim_tag = etree.Comment(TRIM_BEGIN) + e = etree.fromstring(text) + for master in e[::-1]: + if master.tag in MASTERS: + break + if master.tag not in MASTERS: + raise ParseError('No master tag found!') + + master.insert(0, trim_tag) + trim_tag.tail = '\n\n\n' + (master.text or '') + master.text = '\n' + return etree.tostring(e, encoding="utf-8") + + +def add_trim_end(text): + trim_tag = etree.Comment(TRIM_END) + e = etree.fromstring(text) + for master in e[::-1]: + if master.tag in MASTERS: + break + if master.tag not in MASTERS: + raise ParseError('No master tag found!') + + master.append(trim_tag) + trim_tag.tail = '\n' + prev = trim_tag.getprevious() + if prev is not None: + prev.tail = (prev.tail or '') + '\n\n\n' + else: + master.text = (master.text or '') + '\n\n\n' + return etree.tostring(e, encoding="utf-8") diff --git a/apps/dvcs/__init__.py b/apps/dvcs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/dvcs/models.py b/apps/dvcs/models.py new file mode 100644 index 00000000..177b0d30 --- /dev/null +++ b/apps/dvcs/models.py @@ -0,0 +1,339 @@ +from datetime import datetime + +from django.db import models +from django.db.models.base import ModelBase +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from mercurial import mdiff, simplemerge +import pickle + + +class Tag(models.Model): + """ + a tag (e.g. document stage) which can be applied to a change + """ + + name = models.CharField(_('name'), max_length=64) + slug = models.SlugField(_('slug'), unique=True, max_length=64, + null=True, blank=True) + ordering = models.IntegerField(_('ordering')) + + _object_cache = {} + + class Meta: + abstract = True + ordering = ['ordering'] + + def __unicode__(self): + return self.name + + @classmethod + def get(cls, slug): + if slug in cls._object_cache: + return cls._object_cache[slug] + else: + obj = cls.objects.get(slug=slug) + cls._object_cache[slug] = obj + return obj + + @staticmethod + def listener_changed(sender, instance, **kwargs): + sender._object_cache = {} + + def next(self): + """ + Returns the next tag - stage to work on. + Returns None for the last stage. + """ + try: + return Tag.objects.filter(ordering__gt=self.ordering)[0] + except IndexError: + return None + +models.signals.pre_save.connect(Tag.listener_changed, sender=Tag) + + +class Change(models.Model): + """ + Single document change related to previous change. The "parent" + argument points to the version against which this change has been + recorded. Initial text will have a null parent. + + Data contains a pickled diff needed to reproduce the initial document. + """ + author = models.ForeignKey(User, null=True, blank=True) + author_name = models.CharField(max_length=128, null=True, blank=True) + author_email = models.CharField(max_length=128, null=True, blank=True) + patch = models.TextField(blank=True) + revision = models.IntegerField(db_index=True) + + parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="children") + + merge_parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="merge_children") + + description = models.TextField(blank=True, default='') + created_at = models.DateTimeField(editable=False, db_index=True, + default=datetime.now) + publishable = models.BooleanField(default=False) + + class Meta: + abstract = True + ordering = ('created_at',) + unique_together = ['tree', 'revision'] + + def __unicode__(self): + return u"Id: %r, Tree %r, Parent %r, Patch '''\n%s'''" % (self.id, self.tree_id, self.parent_id, self.patch) + + def author_str(self): + if self.author: + return "%s %s <%s>" % ( + self.author.first_name, + self.author.last_name, + self.author.email) + else: + return "%s <%s>" % ( + self.author_name, + self.author_email + ) + + + def save(self, *args, **kwargs): + """ + take the next available revision number if none yet + """ + if self.revision is None: + self.revision = self.tree.revision() + 1 + return super(Change, self).save(*args, **kwargs) + + @staticmethod + def make_patch(src, dst): + if isinstance(src, unicode): + src = src.encode('utf-8') + if isinstance(dst, unicode): + dst = dst.encode('utf-8') + return pickle.dumps(mdiff.textdiff(src, dst)) + + def materialize(self): + # special care for merged nodes + if self.parent is None and self.merge_parent is not None: + return self.apply_to(self.merge_parent.materialize()) + + changes = self.tree.change_set.exclude(parent=None).filter( + revision__lte=self.revision).order_by('revision') + text = '' + for change in changes: + text = change.apply_to(text) + return text.decode('utf-8') + + def make_child(self, patch, description, author=None, + author_name=None, author_email=None, tags=None): + ch = self.children.create(patch=patch, + tree=self.tree, author=author, + author_name=author_name, + author_email=author_email, + description=description) + if tags is not None: + ch.tags = tags + return ch + + def make_merge_child(self, patch, description, author=None, + author_name=None, author_email=None, tags=None): + ch = self.merge_children.create(patch=patch, + tree=self.tree, author=author, + author_name=author_name, + author_email=author_email, + description=description, + tags=tags) + if tags is not None: + ch.tags = tags + return ch + + def apply_to(self, text): + return mdiff.patch(text, pickle.loads(self.patch.encode('ascii'))) + + def merge_with(self, other, author=None, + author_name=None, author_email=None, + description=u"Automatic merge."): + assert self.tree_id == other.tree_id # same tree + if other.parent_id == self.pk: + # immediate child + return other + + local = self.materialize() + base = other.merge_parent.materialize() + remote = other.apply_to(base) + + merge = simplemerge.Merge3Text(base, local, remote) + result = ''.join(merge.merge_lines()) + patch = self.make_patch(local, result) + return self.children.create( + patch=patch, merge_parent=other, tree=self.tree, + author=author, + author_name=author_name, + author_email=author_email, + description=description) + + def revert(self, **kwargs): + """ commit this version of a doc as new head """ + self.tree.commit(text=self.materialize(), **kwargs) + + +def create_tag_model(model): + name = model.__name__ + 'Tag' + attrs = { + '__module__': model.__module__, + } + return type(name, (Tag,), attrs) + + +def create_change_model(model): + name = model.__name__ + 'Change' + + attrs = { + '__module__': model.__module__, + 'tree': models.ForeignKey(model, related_name='change_set'), + 'tags': models.ManyToManyField(model.tag_model, related_name='change_set'), + } + return type(name, (Change,), attrs) + + + +class DocumentMeta(ModelBase): + "Metaclass for Document models." + def __new__(cls, name, bases, attrs): + model = super(DocumentMeta, cls).__new__(cls, name, bases, attrs) + if not model._meta.abstract: + # create a real Tag object and `stage' fk + model.tag_model = create_tag_model(model) + models.ForeignKey(model.tag_model, + null=True, blank=True).contribute_to_class(model, 'stage') + + # create real Change model and `head' fk + model.change_model = create_change_model(model) + models.ForeignKey(model.change_model, + null=True, blank=True, default=None, + help_text=_("This document's current head."), + editable=False).contribute_to_class(model, 'head') + + return model + + + +class Document(models.Model): + """ + File in repository. + """ + __metaclass__ = DocumentMeta + + creator = models.ForeignKey(User, null=True, blank=True, editable=False, + related_name="created_documents") + + user = models.ForeignKey(User, null=True, blank=True) + + class Meta: + abstract = True + + def __unicode__(self): + return u"{0}, HEAD: {1}".format(self.id, self.head_id) + + @models.permalink + def get_absolute_url(self): + return ('dvcs.views.document_data', (), { + 'document_id': self.id, + 'version': self.head_id, + }) + + def materialize(self, change=None): + if self.head is None: + return u'' + if change is None: + change = self.head + elif not isinstance(change, Change): + change = self.change_set.get(pk=change) + return change.materialize() + + def commit(self, **kwargs): + if 'parent' not in kwargs: + parent = self.head + else: + parent = kwargs['parent'] + if not isinstance(parent, Change): + parent = self.change_set.objects.get(pk=kwargs['parent']) + + if 'patch' not in kwargs: + if 'text' not in kwargs: + raise ValueError("You must provide either patch or target document.") + patch = Change.make_patch(self.materialize(change=parent), kwargs['text']) + else: + if 'text' in kwargs: + raise ValueError("You can provide only text or patch - not both") + patch = kwargs['patch'] + + author = kwargs.get('author', None) + author_name = kwargs.get('author_name', None) + author_email = kwargs.get('author_email', None) + tags = kwargs.get('tags', []) + if tags: + # set stage to next tag after the commited one + self.stage = max(tags, key=lambda t: t.ordering).next() + + old_head = self.head + if parent != old_head: + change = parent.make_merge_child(patch, author=author, + author_name=author_name, + author_email=author_email, + description=kwargs.get('description', ''), + tags=tags) + # not Fast-Forward - perform a merge + self.head = old_head.merge_with(change, author=author, + author_name=author_name, + author_email=author_email) + else: + self.head = parent.make_child(patch, author=author, + author_name=author_name, + author_email=author_email, + description=kwargs.get('description', ''), + tags=tags) + + self.save() + return self.head + + def history(self): + return self.change_set.filter(revision__gt=-1) + + def revision(self): + rev = self.change_set.aggregate( + models.Max('revision'))['revision__max'] + return rev if rev is not None else -1 + + def at_revision(self, rev): + if rev is not None: + return self.change_set.get(revision=rev) + else: + return self.head + + def publishable(self): + changes = self.change_set.filter(publishable=True).order_by('-created_at')[:1] + if changes.count(): + return changes[0] + else: + return None + + @staticmethod + def listener_initial_commit(sender, instance, created, **kwargs): + # run for Document and its subclasses + if not isinstance(instance, Document): + return + if created: + instance.head = instance.change_model.objects.create( + revision=-1, + author=instance.creator, + patch=Change.make_patch('', ''), + tree=instance) + instance.save() + +models.signals.post_save.connect(Document.listener_initial_commit) diff --git a/apps/dvcs/tests.py b/apps/dvcs/tests.py new file mode 100644 index 00000000..0c712957 --- /dev/null +++ b/apps/dvcs/tests.py @@ -0,0 +1,164 @@ +from django.test import TestCase +from dvcs.models import Change, Document +from django.contrib.auth.models import User + +class DocumentModelTests(TestCase): + + def setUp(self): + self.user = User.objects.create_user("tester", "tester@localhost.local") + + def assertTextEqual(self, given, expected): + return self.assertEqual(given, expected, + "Expected '''%s'''\n differs from text: '''%s'''" % (expected, given) + ) + + def test_empty_file(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.materialize(), u"") + + def test_single_commit(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + doc.commit(text=u"Ala ma kota", description="Commit #1", author=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 2) + self.assertEqual(doc.materialize(), u"Ala ma kota") + + def test_chained_commits(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + c1 = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 is cool + """, author=self.user) + c2 = doc.commit(description="Commit #2", text=u""" + Line #1 + Line #2 is hot + """, author=self.user) + c3 = doc.commit(description="Commit #3", text=u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """, author=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 4) + + self.assertEqual(doc.materialize(), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c3), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c2), u""" + Line #1 + Line #2 is hot + """) + self.assertEqual(doc.materialize(version=c1), """ + Line #1 + Line #2 is cool + """) + + + def test_parallel_commit_noconflict(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 +""", author=self.user) + + c1 = doc.commit(description="Commit #2", text=u""" + Line #1 is hot + Line #2 +""", parent=base, author=self.user) + self.assertTextEqual(c1.materialize(), u""" + Line #1 is hot + Line #2 +""") + c2 = doc.commit(description="Commit #3", text=u""" + Line #1 + Line #2 + Line #3 +""", parent=base, author=self.user) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" + Line #1 is hot + Line #2 + Line #3 +""") + + def test_parallel_commit_conflict(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" +Line #1 +Line #2 +Line #3 +""", author=self.user) + + c1 = doc.commit(description="Commit #2", text=u""" +Line #1 +Line #2 is hot +Line #3 +""", parent=base, author=self.user) + c2 = doc.commit(description="Commit #3", text=u""" +Line #1 +Line #2 is cool +Line #3 +""", parent=base, author=self.user) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" +Line #1 +<<<<<<< +Line #2 is hot +======= +Line #2 is cool +>>>>>>> +Line #3 +""") + + def test_multiply_parallel_commits(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + c1 = doc.commit(description="Commit A1", text=u""" +Line #1 + +Line #2 + +Line #3 +""", author=self.user) + c2 = doc.commit(description="Commit A2", text=u""" +Line #1 * + +Line #2 + +Line #3 +""", author=self.user) + c3 = doc.commit(description="Commit B1", text=u""" +Line #1 + +Line #2 ** + +Line #3 +""", parent=c1, author=self.user) + c4 = doc.commit(description="Commit C1", text=u""" +Line #1 * + +Line #2 + +Line #3 *** +""", parent=c2, author=self.user) + self.assertEqual(doc.change_set.count(), 7) + self.assertTextEqual(doc.materialize(), u""" +Line #1 * + +Line #2 ** + +Line #3 *** +""") + diff --git a/apps/dvcs/urls.py b/apps/dvcs/urls.py new file mode 100644 index 00000000..d1e1e296 --- /dev/null +++ b/apps/dvcs/urls.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 +from django.conf.urls.defaults import * + +urlpatterns = patterns('dvcs.views', + url(r'^data/(?P[^/]+)/(?P.*)$', 'document_data', name='storage_document_data'), +) diff --git a/apps/dvcs/views.py b/apps/dvcs/views.py new file mode 100644 index 00000000..7918e96c --- /dev/null +++ b/apps/dvcs/views.py @@ -0,0 +1,21 @@ +# Create your views here. +from django.views.generic.simple import direct_to_template +from django import http +from dvcs.models import Document + +def document_list(request, template_name="dvcs/document_list.html"): + return direct_to_template(request, template_name, { + "documents": Document.objects.all(), + }) + +def document_data(request, document_id, version=None): + doc = Document.objects.get(pk=document_id) + return http.HttpResponse(doc.materialize(version or None), content_type="text/plain") + +def document_history(request, docid, template_name="dvcs/document_history.html"): + document = Document.objects.get(pk=docid) + return direct_to_template(request, template_name, { + "document": document, + "changes": document.history(), + }) + diff --git a/apps/wiki/constants.py b/apps/wiki/constants.py deleted file mode 100644 index 6781a48e..00000000 --- a/apps/wiki/constants.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from django.utils.translation import ugettext_lazy as _ - -DOCUMENT_STAGES = ( - ("", u"-----"), - ("first_correction", _(u"First correction")), - ("tagging", _(u"Tagging")), - ("proofreading", _(u"Initial Proofreading")), - ("annotation-proofreading", _(u"Annotation Proofreading")), - ("modernisation", _(u"Modernisation")), - ("annotations", _(u"Annotations")), - ("themes", _(u"Themes")), - ("editor-proofreading", _(u"Editor's Proofreading")), - ("technical-editor-proofreading", _(u"Technical Editor's Proofreading")), -) - -DOCUMENT_TAGS = DOCUMENT_STAGES + \ - (("ready-to-publish", _(u"Ready to publish")),) - -DOCUMENT_TAGS_DICT = dict(DOCUMENT_TAGS) -DOCUMENT_STAGES_DICT = dict(DOCUMENT_STAGES) diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py index 74934bd2..a8a57c88 100644 --- a/apps/wiki/forms.py +++ b/apps/wiki/forms.py @@ -4,64 +4,22 @@ # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # from django import forms -from wiki.constants import DOCUMENT_TAGS, DOCUMENT_STAGES from django.utils.translation import ugettext_lazy as _ +from catalogue.models import Chunk -class DocumentTagForm(forms.Form): + +class DocumentPubmarkForm(forms.Form): """ - Form for tagging revisions. + Form for marking revisions for publishing. """ id = forms.CharField(widget=forms.HiddenInput) - tag = forms.ChoiceField(choices=DOCUMENT_TAGS) + publishable = forms.BooleanField(required=False, initial=True, + label=_('Publishable')) revision = forms.IntegerField(widget=forms.HiddenInput) -class DocumentCreateForm(forms.Form): - """ - Form used for creating new documents. - """ - title = forms.CharField() - id = forms.RegexField(regex=ur"^[-\wąćęłńóśźżĄĆĘŁŃÓŚŹŻ]+$") - file = forms.FileField(required=False) - text = forms.CharField(required=False, widget=forms.Textarea) - - def clean(self): - file = self.cleaned_data['file'] - - if file is not None: - try: - self.cleaned_data['text'] = file.read().decode('utf-8') - except UnicodeDecodeError: - raise forms.ValidationError("Text file must be UTF-8 encoded.") - - if not self.cleaned_data["text"]: - raise forms.ValidationError("You must either enter text or upload a file") - - return self.cleaned_data - - -class DocumentsUploadForm(forms.Form): - """ - Form used for uploading new documents. - """ - file = forms.FileField(required=True, label=_('ZIP file')) - - def clean(self): - file = self.cleaned_data['file'] - - import zipfile - try: - z = self.cleaned_data['zip'] = zipfile.ZipFile(file) - except zipfile.BadZipfile: - raise forms.ValidationError("Should be a ZIP file.") - if z.testzip(): - raise forms.ValidationError("ZIP file corrupt.") - - return self.cleaned_data - - class DocumentTextSaveForm(forms.Form): """ Form for saving document's text: @@ -94,8 +52,8 @@ class DocumentTextSaveForm(forms.Form): help_text=_(u"Describe changes you made."), ) - stage_completed = forms.ChoiceField( - choices=DOCUMENT_STAGES, + stage_completed = forms.ModelChoiceField( + queryset=Chunk.tag_model.objects.all(), required=False, label=_(u"Completed"), help_text=_(u"If you completed a life cycle stage, select it."), diff --git a/apps/wiki/helpers.py b/apps/wiki/helpers.py index f072ef91..dace3d00 100644 --- a/apps/wiki/helpers.py +++ b/apps/wiki/helpers.py @@ -1,8 +1,9 @@ +from datetime import datetime +from functools import wraps + from django import http from django.utils import simplejson as json from django.utils.functional import Promise -from datetime import datetime -from functools import wraps class ExtendedEncoder(json.JSONEncoder): @@ -58,74 +59,3 @@ def ajax_require_permission(permission): return view(request, *args, **kwargs) return authorized_view return decorator - -import collections - -def recursive_groupby(iterable): - """ -# >>> recursive_groupby([1,2,3,4,5]) -# [1, 2, 3, 4, 5] - - >>> recursive_groupby([[1]]) - [1] - - >>> recursive_groupby([('a', 1),('a', 2), 3, ('b', 4), 5]) - ['a', [1, 2], 3, 'b', [4], 5] - - >>> recursive_groupby([('a', 'x', 1),('a', 'x', 2), ('a', 'x', 3)]) - ['a', ['x', [1, 2, 3]]] - - """ - - def _generator(iterator): - group = None - grouper = None - - for item in iterator: - if not isinstance(item, collections.Sequence): - if grouper is not None: - yield grouper - if len(group): - yield recursive_groupby(group) - group = None - grouper = None - yield item - continue - elif len(item) == 1: - if grouper is not None: - yield grouper - if len(group): - yield recursive_groupby(group) - group = None - grouper = None - yield item[0] - continue - elif not len(item): - continue - - if grouper is None: - group = [item[1:]] - grouper = item[0] - continue - - if grouper != item[0]: - if grouper is not None: - yield grouper - if len(group): - yield recursive_groupby(group) - group = None - grouper = None - group = [item[1:]] - grouper = item[0] - continue - - group.append(item[1:]) - - if grouper is not None: - yield grouper - if len(group): - yield recursive_groupby(group) - group = None - grouper = None - - return list(_generator(iterable)) diff --git a/apps/wiki/locale/pl/LC_MESSAGES/django.mo b/apps/wiki/locale/pl/LC_MESSAGES/django.mo index 8ec3fdf2..f841945b 100644 Binary files a/apps/wiki/locale/pl/LC_MESSAGES/django.mo and b/apps/wiki/locale/pl/LC_MESSAGES/django.mo differ diff --git a/apps/wiki/locale/pl/LC_MESSAGES/django.po b/apps/wiki/locale/pl/LC_MESSAGES/django.po index 0dbb13a5..c760f3a7 100644 --- a/apps/wiki/locale/pl/LC_MESSAGES/django.po +++ b/apps/wiki/locale/pl/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Platforma Redakcyjna\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2011-03-07 12:31+0100\n" -"PO-Revision-Date: 2011-03-07 12:31+0100\n" +"POT-Creation-Date: 2011-06-21 13:41+0200\n" +"PO-Revision-Date: 2011-06-21 13:46+0100\n" "Last-Translator: Radek Czajka \n" "Language-Team: Fundacja Nowoczesna Polska \n" "Language: \n" @@ -16,130 +16,223 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: constants.py:6 -msgid "First correction" -msgstr "Autokorekta" - -#: constants.py:7 -msgid "Tagging" -msgstr "Tagowanie" - -#: constants.py:8 -msgid "Initial Proofreading" -msgstr "Korekta" - -#: constants.py:9 -msgid "Annotation Proofreading" -msgstr "Sprawdzenie przypisów źródła" - -#: constants.py:10 -msgid "Modernisation" -msgstr "Uwspółcześnienie" - -#: constants.py:11 -#: templates/wiki/tabs/annotations_view_item.html:3 -msgid "Annotations" -msgstr "Przypisy" - -#: constants.py:12 -msgid "Themes" -msgstr "Motywy" - -#: constants.py:13 -msgid "Editor's Proofreading" -msgstr "Ostateczna redakcja literacka" - -#: constants.py:14 -msgid "Technical Editor's Proofreading" -msgstr "Ostateczna redakcja techniczna" - -#: constants.py:18 -msgid "Ready to publish" +#: forms.py:32 +msgid "Publishable" msgstr "Gotowe do publikacji" -#: forms.py:49 +#: forms.py:68 msgid "ZIP file" msgstr "Plik ZIP" -#: forms.py:80 -#: forms.py:118 +#: forms.py:99 +#: forms.py:137 msgid "Author" msgstr "Autor" -#: forms.py:81 -#: forms.py:119 +#: forms.py:100 +#: forms.py:138 msgid "Your name" msgstr "Imię i nazwisko" -#: forms.py:86 -#: forms.py:124 +#: forms.py:105 +#: forms.py:143 msgid "Author's email" msgstr "E-mail autora" -#: forms.py:87 -#: forms.py:125 +#: forms.py:106 +#: forms.py:144 msgid "Your email address, so we can show a gravatar :)" msgstr "Adres e-mail, żebyśmy mogli pokazać gravatar :)" -#: forms.py:93 -#: forms.py:131 +#: forms.py:112 +#: forms.py:150 msgid "Your comments" msgstr "Twój komentarz" -#: forms.py:94 +#: forms.py:113 msgid "Describe changes you made." msgstr "Opisz swoje zmiany" -#: forms.py:100 +#: forms.py:119 msgid "Completed" msgstr "Ukończono" -#: forms.py:101 +#: forms.py:120 msgid "If you completed a life cycle stage, select it." msgstr "Jeśli został ukończony etap prac, wskaż go." -#: forms.py:132 +#: forms.py:151 msgid "Describe the reason for reverting." msgstr "Opisz powód przywrócenia." -#: models.py:93 -#, python-format -msgid "Finished stage: %s" -msgstr "Ukończony etap: %s" +#: forms.py:176 +#: forms.py:190 +msgid "Chunk with this slug already exists" +msgstr "Część z tym slugiem już istnieje" + +#: forms.py:202 +msgid "Append to" +msgstr "Dołącz do" + +#: models.py:25 +msgid "title" +msgstr "tytuł" + +#: models.py:26 +msgid "slug" +msgstr "" + +#: models.py:27 +msgid "scan gallery name" +msgstr "nazwa galerii skanów" + +#: models.py:29 +msgid "parent" +msgstr "rodzic" + +#: models.py:30 +msgid "parent number" +msgstr "numeracja rodzica" + +#: models.py:40 +msgid "book" +msgstr "książka" + +#: models.py:41 +msgid "books" +msgstr "książki" -#: models.py:152 +#: models.py:206 msgid "name" msgstr "nazwa" -#: models.py:156 +#: models.py:210 msgid "theme" msgstr "motyw" -#: models.py:157 +#: models.py:211 msgid "themes" msgstr "motywy" -#: views.py:168 +#: views.py:241 #, python-format -msgid "Title already used for %s" -msgstr "Nazwa taka sama jak dla pliku %s" +msgid "Slug already used for %s" +msgstr "Slug taki sam jak dla pliku %s" -#: views.py:170 -msgid "Title already used in repository." -msgstr "Plik o tej nazwie już istnieje w repozytorium." +#: views.py:243 +msgid "Slug already used in repository." +msgstr "Dokument o tym slugu już istnieje w repozytorium." -#: views.py:176 +#: views.py:249 msgid "File should be UTF-8 encoded." msgstr "Plik powinien mieć kodowanie UTF-8." -#: views.py:379 +#: views.py:655 msgid "Tag added" msgstr "Dodano tag" -#: templates/wiki/base.html:15 +#: views.py:677 +msgid "Revision marked" +msgstr "Wersja oznaczona" + +#: views.py:679 +msgid "Nothing changed" +msgstr "Nic nie uległo zmianie" + +#: templates/wiki/base.html:9 msgid "Platforma Redakcyjna" msgstr "" +#: templates/wiki/book_append_to.html:8 +msgid "Append book" +msgstr "Dołącz książkę" + +#: templates/wiki/book_detail.html:6 +#: templates/wiki/book_detail.html.py:46 +msgid "edit" +msgstr "edytuj" + +#: templates/wiki/book_detail.html:16 +msgid "add basic document structure" +msgstr "dodaj podstawową strukturę dokumentu" + +#: templates/wiki/book_detail.html:20 +msgid "change master tag to" +msgstr "zmień tak master na" + +#: templates/wiki/book_detail.html:24 +msgid "add begin trimming tag" +msgstr "dodaj początkowy ogranicznik" + +#: templates/wiki/book_detail.html:28 +msgid "add end trimming tag" +msgstr "dodaj końcowy ogranicznik" + +#: templates/wiki/book_detail.html:34 +msgid "unstructured text" +msgstr "tekst bez struktury" + +#: templates/wiki/book_detail.html:38 +msgid "unknown XML" +msgstr "nieznany XML" + +#: templates/wiki/book_detail.html:42 +msgid "broken document" +msgstr "uszkodzony dokument" + +#: templates/wiki/book_detail.html:60 +msgid "Apply fixes" +msgstr "Wykonaj zmiany" + +#: templates/wiki/book_detail.html:66 +msgid "Append to other book" +msgstr "Dołącz do innej książki" + +#: templates/wiki/book_detail.html:68 +msgid "Last published" +msgstr "Ostatnio opublikowano" + +#: templates/wiki/book_detail.html:72 +msgid "Full XML" +msgstr "Pełny XML" + +#: templates/wiki/book_detail.html:73 +msgid "HTML version" +msgstr "Wersja HTML" + +#: templates/wiki/book_detail.html:74 +msgid "TXT version" +msgstr "Wersja TXT" + +#: templates/wiki/book_detail.html:76 +msgid "EPUB version" +msgstr "Wersja EPUB" + +#: templates/wiki/book_detail.html:77 +msgid "PDF version" +msgstr "Wersja PDF" + +#: templates/wiki/book_detail.html:90 +#: templates/wiki/tabs/summary_view.html:30 +msgid "Publish" +msgstr "Opublikuj" + +#: templates/wiki/book_detail.html:94 +msgid "This book cannot be published yet" +msgstr "Ta książka nie może jeszcze zostać opublikowana" + +#: templates/wiki/book_edit.html:8 +#: templates/wiki/chunk_edit.html:8 +#: templates/wiki/document_details_base.html:35 +#: templates/wiki/pubmark_dialog.html:15 +#: templates/wiki/tag_dialog.html:15 +msgid "Save" +msgstr "Zapisz" + +#: templates/wiki/chunk_add.html:8 +msgid "Add chunk" +msgstr "Dodaj część" + #: templates/wiki/diff_table.html:5 msgid "Old version" msgstr "Stara wersja" @@ -156,92 +249,102 @@ msgstr "Utwórz dokument" msgid "Click to open/close gallery" msgstr "Kliknij, aby (ro)zwinąć galerię" -#: templates/wiki/document_details_base.html:36 +#: templates/wiki/document_details_base.html:31 msgid "Help" msgstr "Pomoc" -#: templates/wiki/document_details_base.html:38 +#: templates/wiki/document_details_base.html:33 msgid "Version" msgstr "Wersja" -#: templates/wiki/document_details_base.html:38 +#: templates/wiki/document_details_base.html:33 msgid "Unknown" msgstr "nieznana" -#: templates/wiki/document_details_base.html:40 -#: templates/wiki/tag_dialog.html:15 -msgid "Save" -msgstr "Zapisz" - -#: templates/wiki/document_details_base.html:41 +#: templates/wiki/document_details_base.html:36 msgid "Save attempt in progress" msgstr "Trwa zapisywanie" -#: templates/wiki/document_details_base.html:42 +#: templates/wiki/document_details_base.html:37 msgid "There is a newer version of this document!" msgstr "Istnieje nowsza wersja tego dokumentu!" -#: templates/wiki/document_list.html:30 +#: templates/wiki/document_list.html:31 msgid "Clear filter" msgstr "Wyczyść filtr" -#: templates/wiki/document_list.html:48 +#: templates/wiki/document_list.html:46 +msgid "No books found." +msgstr "Nie znaleziono książek." + +#: templates/wiki/document_list.html:89 msgid "Your last edited documents" msgstr "Twoje ostatnie edycje" -#: templates/wiki/document_upload.html:9 +#: templates/wiki/document_upload.html:8 msgid "Bulk documents upload" msgstr "Hurtowe dodawanie dokumentów" -#: templates/wiki/document_upload.html:12 +#: templates/wiki/document_upload.html:11 msgid "Please submit a ZIP with UTF-8 encoded XML files. Files not ending with .xml will be ignored." msgstr "Proszę wskazać archiwum ZIP z plikami XML w kodowaniu UTF-8. Pliki nie kończące się na .xml zostaną zignorowane." -#: templates/wiki/document_upload.html:17 +#: templates/wiki/document_upload.html:16 +#: templatetags/wiki.py:36 msgid "Upload" -msgstr "Dodaj" +msgstr "Załaduj" -#: templates/wiki/document_upload.html:24 +#: templates/wiki/document_upload.html:23 msgid "There have been some errors. No files have been added to the repository." msgstr "Wystąpiły błędy. Żadne pliki nie zostały dodane do repozytorium." -#: templates/wiki/document_upload.html:25 +#: templates/wiki/document_upload.html:24 msgid "Offending files" msgstr "Błędne pliki" -#: templates/wiki/document_upload.html:33 +#: templates/wiki/document_upload.html:32 msgid "Correct files" msgstr "Poprawne pliki" -#: templates/wiki/document_upload.html:44 +#: templates/wiki/document_upload.html:43 msgid "Files have been successfully uploaded to the repository." msgstr "Pliki zostały dodane do repozytorium." -#: templates/wiki/document_upload.html:45 +#: templates/wiki/document_upload.html:44 msgid "Uploaded files" msgstr "Dodane pliki" -#: templates/wiki/document_upload.html:55 +#: templates/wiki/document_upload.html:54 msgid "Skipped files" msgstr "Pominięte pliki" -#: templates/wiki/document_upload.html:56 +#: templates/wiki/document_upload.html:55 msgid "Files skipped due to no .xml extension" msgstr "Pliki pominięte z powodu braku rozszerzenia .xml." -#: templates/wiki/revert_dialog.html:38 -msgid "Revert" -msgstr "Przywróć" - +#: templates/wiki/pubmark_dialog.html:16 #: templates/wiki/revert_dialog.html:39 #: templates/wiki/tag_dialog.html:16 msgid "Cancel" msgstr "Anuluj" +#: templates/wiki/revert_dialog.html:38 +msgid "Revert" +msgstr "Przywróć" + +#: templates/wiki/user_list.html:7 +#: templatetags/wiki.py:33 +msgid "Users" +msgstr "Użytkownicy" + #: templates/wiki/tabs/annotations_view.html:9 msgid "all" msgstr "wszystkie" +#: templates/wiki/tabs/annotations_view_item.html:3 +msgid "Annotations" +msgstr "Przypisy" + #: templates/wiki/tabs/gallery_view.html:7 msgid "Previous" msgstr "Poprzednie" @@ -267,8 +370,8 @@ msgid "Compare versions" msgstr "Porównaj wersje" #: templates/wiki/tabs/history_view.html:7 -msgid "Mark version" -msgstr "Oznacz wersję" +msgid "Mark for publishing" +msgstr "Oznacz do publikacji" #: templates/wiki/tabs/history_view.html:9 msgid "Revert document" @@ -315,43 +418,27 @@ msgstr "Znajdź i zamień" msgid "Source code" msgstr "Kod źródłowy" -#: templates/wiki/tabs/summary_view.html:10 +#: templates/wiki/tabs/summary_view.html:9 msgid "Title" msgstr "Tytuł" -#: templates/wiki/tabs/summary_view.html:15 +#: templates/wiki/tabs/summary_view.html:14 msgid "Document ID" msgstr "ID dokumentu" -#: templates/wiki/tabs/summary_view.html:19 +#: templates/wiki/tabs/summary_view.html:18 msgid "Current version" msgstr "Aktualna wersja" -#: templates/wiki/tabs/summary_view.html:22 +#: templates/wiki/tabs/summary_view.html:21 msgid "Last edited by" msgstr "Ostatnio edytowane przez" -#: templates/wiki/tabs/summary_view.html:26 +#: templates/wiki/tabs/summary_view.html:25 msgid "Link to gallery" msgstr "Link do galerii" -#: templates/wiki/tabs/summary_view.html:31 -msgid "Characters in document" -msgstr "Znaków w dokumencie" - -#: templates/wiki/tabs/summary_view.html:32 -msgid "pages" -msgstr "stron" - -#: templates/wiki/tabs/summary_view.html:32 -msgid "untagged" -msgstr "nieotagowane" - -#: templates/wiki/tabs/summary_view.html:35 -msgid "Publish" -msgstr "Opublikuj" - -#: templates/wiki/tabs/summary_view_item.html:4 +#: templates/wiki/tabs/summary_view_item.html:3 msgid "Summary" msgstr "Podsumowanie" @@ -367,8 +454,55 @@ msgstr "Wstaw przypis" msgid "Visual editor" msgstr "Edytor wizualny" +#: templatetags/wiki.py:30 +msgid "Assigned to me" +msgstr "Przypisane do mnie" + +#: templatetags/wiki.py:32 +msgid "Unassigned" +msgstr "Nie przypisane" + +#: templatetags/wiki.py:34 +msgid "All" +msgstr "Wszystkie" + +#: templatetags/wiki.py:35 +msgid "Add" +msgstr "Dodaj" + +#: templatetags/wiki.py:39 +msgid "Admin" +msgstr "Administracja" + +#~ msgid "First correction" +#~ msgstr "Autokorekta" + +#~ msgid "Tagging" +#~ msgstr "Tagowanie" + +#~ msgid "Initial Proofreading" +#~ msgstr "Korekta" + +#~ msgid "Annotation Proofreading" +#~ msgstr "Sprawdzenie przypisów źródła" + +#~ msgid "Modernisation" +#~ msgstr "Uwspółcześnienie" + +#~ msgid "Themes" +#~ msgstr "Motywy" + +#~ msgid "Editor's Proofreading" +#~ msgstr "Ostateczna redakcja literacka" + +#~ msgid "Technical Editor's Proofreading" +#~ msgstr "Ostateczna redakcja techniczna" + +#~ msgid "Finished stage: %s" +#~ msgstr "Ukończony etap: %s" + #~ msgid "Refresh" #~ msgstr "Odśwież" + #~ msgid "Insert special character" #~ msgstr "Wstaw znak specjalny" - diff --git a/apps/wiki/models.py b/apps/wiki/models.py index 7cb20c65..c539908d 100644 --- a/apps/wiki/models.py +++ b/apps/wiki/models.py @@ -4,150 +4,12 @@ # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # from django.db import models -import re -import os -import vstorage -from vstorage import DocumentNotFound -from wiki import settings, constants -from slughifi import slughifi from django.utils.translation import ugettext_lazy as _ -from django.http import Http404 - import logging logger = logging.getLogger("fnp.wiki") -# _PCHARS_DICT = dict(zip((ord(x) for x in u"ĄĆĘŁŃÓŚŻŹąćęłńóśżź "), u"ACELNOSZZacelnoszz_")) -_PCHARS_DICT = dict(zip((ord(x) for x in u" "), u"_")) - -# I know this is barbaric, but I didn't find a better solution ;( -def split_name(name): - parts = name.translate(_PCHARS_DICT).split('__') - return parts - -def join_name(*parts, **kwargs): - name = u'__'.join(p.translate(_PCHARS_DICT) for p in parts) - logger.info("JOIN %r -> %r", parts, name) - return name - -def normalize_name(name): - """ - >>> normalize_name("gąska".decode('utf-8')) - u'g\u0105ska' - """ - return unicode(name).translate(_PCHARS_DICT) - -STAGE_TAGS_RE = re.compile(r'^#stage-finished: (.*)$', re.MULTILINE) - - -class DocumentStorage(object): - def __init__(self, path): - self.vstorage = vstorage.VersionedStorage(path) - - def get(self, name, revision=None): - text, rev = self.vstorage.page_text(name, revision) - return Document(self, name=name, text=text, revision=rev) - - def get_by_tag(self, name, tag): - text, rev = self.vstorage.page_text_by_tag(name, tag) - return Document(self, name=name, text=text, revision=rev) - - def revert(self, name, revision, **commit_args): - self.vstorage.revert(name, revision, **commit_args) - - def get_or_404(self, *args, **kwargs): - try: - return self.get(*args, **kwargs) - except DocumentNotFound: - raise Http404 - - def put(self, document, author, comment, parent=None): - self.vstorage.save_text( - title=document.name, - text=document.text, - author=author, - comment=comment, - parent=parent) - - return document - - def create_document(self, text, name): - title = u', '.join(p.title() for p in split_name(name)) - - if text is None: - text = u'' - - document = Document(self, name=name, text=text, title=title) - return self.put(document, u"", u"Document created.") - - def delete(self, name, author, comment): - self.vstorage.delete_page(name, author, comment) - - def all(self): - return list(self.vstorage.all_pages()) - - def history(self, title): - def stage_desc(match): - stage = match.group(1) - return _("Finished stage: %s") % constants.DOCUMENT_STAGES_DICT[stage] - - for changeset in self.vstorage.page_history(title): - changeset['description'] = STAGE_TAGS_RE.sub(stage_desc, changeset['description']) - yield changeset - - def doc_meta(self, title, revision=None): - return self.vstorage.page_meta(title, revision) - - - -class Document(object): - META_REGEX = re.compile(r'\s*', re.DOTALL | re.MULTILINE) - - def __init__(self, storage, **kwargs): - self.storage = storage - for attr, value in kwargs.iteritems(): - setattr(self, attr, value) - - def add_tag(self, tag, revision, author): - """ Add document specific tag """ - logger.debug("Adding tag %s to doc %s version %d", tag, self.name, revision) - self.storage.vstorage.add_page_tag(self.name, revision, tag, user=author) - - @property - def plain_text(self): - return re.sub(self.META_REGEX, '', self.text, 1) - - def meta(self): - result = {} - - m = re.match(self.META_REGEX, self.text) - if m: - for line in m.group(1).split('\n'): - try: - k, v = line.split(':', 1) - result[k.strip()] = v.strip() - except ValueError: - continue - - gallery = result.get('gallery', slughifi(self.name.replace(' ', '_'))) - - if gallery.startswith('/'): - gallery = os.path.basename(gallery) - - result['gallery'] = gallery - return result - - def info(self): - return self.storage.vstorage.page_meta(self.name, self.revision) - -def getstorage(): - return DocumentStorage(settings.REPOSITORY_PATH) - -# -# Django models -# - class Theme(models.Model): name = models.CharField(_('name'), max_length=50, unique=True) diff --git a/apps/wiki/settings.py b/apps/wiki/settings.py index 0a227e4a..50f49d8b 100644 --- a/apps/wiki/settings.py +++ b/apps/wiki/settings.py @@ -1,7 +1,3 @@ from django.conf import settings -if not hasattr(settings, 'WIKI_REPOSITORY_PATH'): - raise Exception('You must set WIKI_REPOSITORY_PATH in your settings file.') - -REPOSITORY_PATH = settings.WIKI_REPOSITORY_PATH GALLERY_URL = settings.MEDIA_URL + 'images/' diff --git a/apps/wiki/templates/wiki/base.html b/apps/wiki/templates/wiki/base.html deleted file mode 100644 index f88fac31..00000000 --- a/apps/wiki/templates/wiki/base.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% load compressed i18n %} - -{% block title %}{{ document_name }} - {{ block.super }}{% endblock %} - -{% block extrahead %} -{% compressed_css 'listing' %} -{% endblock %} - -{% block extrabody %} -{% compressed_js 'listing' %} -{% endblock %} - -{% block maincontent %} -

{% trans "Platforma Redakcyjna" %}

-
- {% block leftcolumn %} - {% endblock leftcolumn %} -
-
- {% block rightcolumn %} - {% endblock rightcolumn %} -
-{% endblock maincontent %} \ No newline at end of file diff --git a/apps/wiki/templates/wiki/document_create_missing.html b/apps/wiki/templates/wiki/document_create_missing.html deleted file mode 100644 index 351e87a2..00000000 --- a/apps/wiki/templates/wiki/document_create_missing.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "wiki/base.html" %} -{% load i18n %} - -{% block leftcolumn %} -
- {{ form.as_p }} - -

-
-{% endblock leftcolumn %} - -{% block rightcolumn %} -{% endblock rightcolumn %} \ No newline at end of file diff --git a/apps/wiki/templates/wiki/document_details.html b/apps/wiki/templates/wiki/document_details.html index 934a8ac6..d95603f6 100644 --- a/apps/wiki/templates/wiki/document_details.html +++ b/apps/wiki/templates/wiki/document_details.html @@ -43,4 +43,5 @@ {% include "wiki/save_dialog.html" %} {% include "wiki/revert_dialog.html" %} {% include "wiki/tag_dialog.html" %} + {% include "wiki/pubmark_dialog.html" %} {% endblock %} diff --git a/apps/wiki/templates/wiki/document_details_base.html b/apps/wiki/templates/wiki/document_details_base.html index 03231330..76711dd9 100644 --- a/apps/wiki/templates/wiki/document_details_base.html +++ b/apps/wiki/templates/wiki/document_details_base.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load toolbar_tags i18n %} -{% block title %}{{ document.name }} - {{ block.super }}{% endblock %} +{% block title %}{{ book.title }} - {{ block.super }}{% endblock %} {% block extrahead %} {% load compressed %} {% compressed_css 'detail' %} @@ -16,21 +16,17 @@ {% block maincontent %}